diff --git a/doc/source/user/proxies/placement.rst b/doc/source/user/proxies/placement.rst index b7bd15625..864b41204 100644 --- a/doc/source/user/proxies/placement.rst +++ b/doc/source/user/proxies/placement.rst @@ -40,3 +40,10 @@ Resource Provider Inventories delete_resource_provider_inventory, get_resource_provider_inventory, resource_provider_inventories + +Traits +^^^^^^ + +.. autoclass:: openstack.placement.v1._proxy.Proxy + :noindex: + :members: create_trait, delete_trait, get_trait, traits diff --git a/doc/source/user/resources/placement/index.rst b/doc/source/user/resources/placement/index.rst index f4fe8dd8f..b57b79740 100644 --- a/doc/source/user/resources/placement/index.rst +++ b/doc/source/user/resources/placement/index.rst @@ -7,3 +7,4 @@ Placement v1 Resources v1/resource_class v1/resource_provider v1/resource_provider_inventory + v1/trait diff --git a/doc/source/user/resources/placement/v1/trait.rst b/doc/source/user/resources/placement/v1/trait.rst new file mode 100644 index 000000000..e9c70a887 --- /dev/null +++ b/doc/source/user/resources/placement/v1/trait.rst @@ -0,0 +1,12 @@ +openstack.placement.v1.trait +============================ + +.. automodule:: openstack.placement.v1.trait + +The Trait Class +--------------- + +The ``Trait`` class inherits from :class:`~openstack.resource.Resource`. + +.. autoclass:: openstack.placement.v1.trait.Trait + :members: diff --git a/openstack/placement/v1/_proxy.py b/openstack/placement/v1/_proxy.py index d3a0b79fc..60768b5a5 100644 --- a/openstack/placement/v1/_proxy.py +++ b/openstack/placement/v1/_proxy.py @@ -15,6 +15,7 @@ from openstack.placement.v1 import resource_provider as _resource_provider from openstack.placement.v1 import ( resource_provider_inventory as _resource_provider_inventory, ) +from openstack.placement.v1 import trait as _trait from openstack import proxy from openstack import resource @@ -409,3 +410,53 @@ class Proxy(proxy.Proxy): resource_provider_id=resource_provider_id, **query, ) + + # ========== Traits ========== + + def create_trait(self, name): + """Create a new trait + + :param name: The name of the new trait + + :returns: The results of trait creation + :rtype: :class:`~openstack.placement.v1.trait.Trait` + """ + return self._create(_trait.Trait, name=name) + + def delete_trait(self, trait, ignore_missing=True): + """Delete a trait + + :param trait: The value can be either the ID of a trait or an + :class:`~openstack.placement.v1.trait.Trait`, instance. + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be raised when + the resource provider inventory does not exist. When set to + ``True``, no exception will be set when attempting to delete a + nonexistent resource provider inventory. + + :returns: ``None`` + """ + self._delete(_trait.Trait, trait, ignore_missing=ignore_missing) + + def get_trait(self, trait): + """Get a single trait + + :param trait: The value can be either the ID of a trait or an + :class:`~openstack.placement.v1.trait.Trait`, instance. + + :returns: An instance of + :class:`~openstack.placement.v1.resource_provider_inventory.ResourceProviderInventory` + :raises: :class:`~openstack.exceptions.ResourceNotFound` when no + trait matching the criteria could be found. + """ + return self._get(_trait.Trait, trait) + + def traits(self, **query): + """Retrieve a generator of traits + + :param query: Optional query parameters to be sent to limit + the resources being returned. + + :returns: A generator of trait objects + """ + return self._list(_trait.Trait, **query) diff --git a/openstack/placement/v1/trait.py b/openstack/placement/v1/trait.py new file mode 100644 index 000000000..d87c60856 --- /dev/null +++ b/openstack/placement/v1/trait.py @@ -0,0 +1,142 @@ +# 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. + +from openstack import exceptions +from openstack import resource + + +class Trait(resource.Resource): + resource_key = None + resources_key = None + base_path = '/traits' + + # Capabilities + + allow_create = True + allow_fetch = True + allow_delete = True + allow_list = True + + create_method = 'PUT' + + # Added in 1.6 + _max_microversion = '1.6' + + _query_mapping = resource.QueryParameters( + 'name', + 'associated', + include_pagination_defaults=False, + ) + + name = resource.Body('name', alternate_id=True) + + @classmethod + def list( + cls, + session, + paginated=True, + base_path=None, + allow_unknown_params=False, + *, + microversion=None, + **params, + ): + """This method is a generator which yields resource objects. + + A re-implementation of :meth:`~openstack.resource.Resource.list` that + handles the list of strings (as opposed to a list of objects) that this + call returns. + + Refer to :meth:`~openstack.resource.Resource.list` for full + documentation including parameter, exception and return type + documentation. + """ + session = cls._get_session(session) + + if microversion is None: + microversion = cls._get_microversion(session, action='list') + + if base_path is None: + base_path = cls.base_path + + # There is no server-side filtering, only client-side + client_filters = {} + # Gather query parameters which are not supported by the server + for k, v in params.items(): + if ( + # Known attr + hasattr(cls, k) + # Is real attr property + and isinstance(getattr(cls, k), resource.Body) + # not included in the query_params + and k not in cls._query_mapping._mapping.keys() + ): + client_filters[k] = v + + uri = base_path % params + uri_params = {} + + for k, v in params.items(): + # We need to gather URI parts to set them on the resource later + if hasattr(cls, k) and isinstance(getattr(cls, k), resource.URI): + uri_params[k] = v + + def _dict_filter(f, d): + """Dict param based filtering""" + if not d: + return False + for key in f.keys(): + if isinstance(f[key], dict): + if not _dict_filter(f[key], d.get(key, None)): + return False + elif d.get(key, None) != f[key]: + return False + return True + + response = session.get( + uri, + headers={"Accept": "application/json"}, + params={}, + microversion=microversion, + ) + exceptions.raise_from_response(response) + data = response.json() + + for trait_name in data['traits']: + trait = { + 'name': trait_name, + **uri_params, + } + value = cls.existing( + microversion=microversion, + connection=session._get_connection(), + **trait, + ) + + filters_matched = True + # Iterate over client filters and return only if matching + for key in client_filters.keys(): + if isinstance(client_filters[key], dict): + if not _dict_filter( + client_filters[key], + value.get(key, None), + ): + filters_matched = False + break + elif value.get(key, None) != client_filters[key]: + filters_matched = False + break + + if filters_matched: + yield value + + return None diff --git a/openstack/resource.py b/openstack/resource.py index 104fcb36f..2551dc88a 100644 --- a/openstack/resource.py +++ b/openstack/resource.py @@ -321,25 +321,32 @@ class _Request: class QueryParameters: - def __init__(self, *names, **mappings): + def __init__( + self, + *names, + include_pagination_defaults=True, + **mappings, + ): """Create a dict of accepted query parameters :param names: List of strings containing client-side query parameter - names. Each name in the list maps directly to the name - expected by the server. - + names. Each name in the list maps directly to the name + expected by the server. :param mappings: Key-value pairs where the key is the client-side - name we'll accept here and the value is the name - the server expects, e.g, changes_since=changes-since. - Additionally, a value can be a dict with optional keys - name - server-side name, - type - callable to convert from client to server - representation. + name we'll accept here and the value is the name + the server expects, e.g, ``changes_since=changes-since``. + Additionally, a value can be a dict with optional keys: - By default, both limit and marker are included in the initial mapping - as they're the most common query parameters used for listing resources. + - ``name`` - server-side name, + - ``type`` - callable to convert from client to server + representation + :param include_pagination_defaults: If true, include default pagination + parameters, ``limit`` and ``marker``. These are the most common + query parameters used for listing resources in OpenStack APIs. """ - self._mapping = {"limit": "limit", "marker": "marker"} + self._mapping = {} + if include_pagination_defaults: + self._mapping.update({"limit": "limit", "marker": "marker"}) self._mapping.update({name: name for name in names}) self._mapping.update(mappings) diff --git a/openstack/tests/functional/placement/v1/test_trait.py b/openstack/tests/functional/placement/v1/test_trait.py new file mode 100644 index 000000000..923f88c1e --- /dev/null +++ b/openstack/tests/functional/placement/v1/test_trait.py @@ -0,0 +1,61 @@ +# 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 uuid + +from openstack.placement.v1 import trait as _trait +from openstack.tests.functional import base + + +class TestTrait(base.BaseFunctionalTest): + def setUp(self): + super().setUp() + + if not self.operator_cloud.has_service('placement'): + self.skipTest('placement service not supported by cloud') + + self.trait_name = f'CUSTOM_{uuid.uuid4().hex.upper()}' + + trait = self.operator_cloud.placement.create_trait( + name=self.trait_name, + ) + self.assertIsInstance(trait, _trait.Trait) + self.assertEqual(self.trait_name, trait.name) + + self.trait = trait + + def tearDown(self): + self.operator_cloud.placement.delete_trait(self.trait) + super().tearDown() + + def test_resource_provider_inventory(self): + # list all traits + + traits = list(self.operator_cloud.placement.traits()) + self.assertIsInstance(traits[0], _trait.Trait) + self.assertIn(self.trait.name, {x.id for x in traits}) + + # (no update_trait method) + + # retrieve details of the trait + + trait = self.operator_cloud.placement.get_trait(self.trait) + self.assertIsInstance(trait, _trait.Trait) + self.assertEqual(self.trait_name, trait.id) + + # retrieve details of the trait using IDs + + trait = self.operator_cloud.placement.get_trait(self.trait_name) + self.assertIsInstance(trait, _trait.Trait) + self.assertEqual(self.trait_name, trait.id) + + # (no find_trait method) diff --git a/openstack/tests/unit/placement/v1/test_trait.py b/openstack/tests/unit/placement/v1/test_trait.py new file mode 100644 index 000000000..777a6d7da --- /dev/null +++ b/openstack/tests/unit/placement/v1/test_trait.py @@ -0,0 +1,42 @@ +# 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. + +from openstack.placement.v1 import trait as _trait +from openstack.tests.unit import base + +FAKE = { + 'name': 'CUSTOM_FOO', +} + + +class TestResourceClass(base.TestCase): + def test_basic(self): + sot = _trait.Trait() + self.assertEqual(None, sot.resource_key) + self.assertEqual(None, sot.resources_key) + self.assertEqual('/traits', sot.base_path) + self.assertTrue(sot.allow_create) + self.assertTrue(sot.allow_fetch) + self.assertFalse(sot.allow_commit) + self.assertTrue(sot.allow_delete) + self.assertTrue(sot.allow_list) + self.assertFalse(sot.allow_patch) + + self.assertDictEqual( + {'name': 'name', 'associated': 'associated'}, + sot._query_mapping._mapping, + ) + + def test_make_it(self): + sot = _trait.Trait(**FAKE) + self.assertEqual(FAKE['name'], sot.id) + self.assertEqual(FAKE['name'], sot.name) diff --git a/releasenotes/notes/add-placement-trait-29957d2c03edbfb9.yaml b/releasenotes/notes/add-placement-trait-29957d2c03edbfb9.yaml new file mode 100644 index 000000000..773d77fd1 --- /dev/null +++ b/releasenotes/notes/add-placement-trait-29957d2c03edbfb9.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Add support for the ``Trait`` Placement resource.