Unify resource list filtering

Extend resource list method to accept all possible filtering parameters.
What is supported by the API are sent to the server. Remaining
parameters are applied to the fetched results. With this
`allow_unknown_params` parameter of the list call is dropped.

Change-Id: Ie9cfb81330d6b98b97b7abad9cf5ae6334ba12e7
This commit is contained in:
Artem Goncharov 2022-08-13 15:34:55 +02:00
parent 9fa6603d4e
commit a0292478c1
7 changed files with 142 additions and 41 deletions

View File

@ -153,8 +153,7 @@ class ComputeCloudMixin(_normalize.Normalizer):
""" """
if not filters: if not filters:
filters = {} filters = {}
return list(self.compute.keypairs(allow_unknown_params=True, return list(self.compute.keypairs(**filters))
**filters))
@_utils.cache_on_arguments() @_utils.cache_on_arguments()
def list_availability_zone_names(self, unavailable=False): def list_availability_zone_names(self, unavailable=False):
@ -353,7 +352,7 @@ class ComputeCloudMixin(_normalize.Normalizer):
return [ return [
self._expand_server(server, detailed, bare) self._expand_server(server, detailed, bare)
for server in self.compute.servers( for server in self.compute.servers(
all_projects=all_projects, allow_unknown_params=True, all_projects=all_projects,
**filters) **filters)
] ]
@ -1504,7 +1503,6 @@ class ComputeCloudMixin(_normalize.Normalizer):
return list(self.compute.hypervisors( return list(self.compute.hypervisors(
details=True, details=True,
allow_unknown_params=True,
**filters)) **filters))
def search_aggregates(self, name_or_id=None, filters=None): def search_aggregates(self, name_or_id=None, filters=None):
@ -1525,7 +1523,7 @@ class ComputeCloudMixin(_normalize.Normalizer):
:returns: A list of compute ``Aggregate`` objects. :returns: A list of compute ``Aggregate`` objects.
""" """
return self.compute.aggregates(allow_unknown_params=True, **filters) return self.compute.aggregates(**filters)
# TODO(stephenfin): This shouldn't return a munch # TODO(stephenfin): This shouldn't return a munch
def get_aggregate(self, name_or_id, filters=None): def get_aggregate(self, name_or_id, filters=None):

View File

@ -57,8 +57,7 @@ class SecurityGroupCloudMixin(_normalize.Normalizer):
# pass filters dict to the list to filter as much as possible on # pass filters dict to the list to filter as much as possible on
# the server side # the server side
return list( return list(
self.network.security_groups(allow_unknown_params=True, self.network.security_groups(**filters))
**filters))
# Handle nova security groups # Handle nova security groups
else: else:

View File

@ -86,8 +86,13 @@ class Flavor(resource.Resource):
return super().__getattribute__(name) return super().__getattribute__(name)
@classmethod @classmethod
def list(cls, session, paginated=True, base_path='/flavors/detail', def list(
allow_unknown_params=False, **params): cls,
session,
paginated=True,
base_path='/flavors/detail',
**params
):
# Find will invoke list when name was passed. Since we want to return # Find will invoke list when name was passed. Since we want to return
# flavor with details (same as direct get) we need to swap default here # flavor with details (same as direct get) we need to swap default here
# and list with "/flavors" if no details explicitely requested # and list with "/flavors" if no details explicitely requested
@ -98,7 +103,6 @@ class Flavor(resource.Resource):
return super(Flavor, cls).list( return super(Flavor, cls).list(
session, paginated=paginated, session, paginated=paginated,
base_path=base_path, base_path=base_path,
allow_unknown_params=allow_unknown_params,
**params) **params)
def _action(self, session, body, microversion=None): def _action(self, session, body, microversion=None):

View File

@ -21,6 +21,7 @@ try:
except ImportError: except ImportError:
JSONDecodeError = ValueError JSONDecodeError = ValueError
import iso8601 import iso8601
import jmespath
from keystoneauth1 import adapter from keystoneauth1 import adapter
from openstack import _log from openstack import _log
@ -637,7 +638,14 @@ class Proxy(adapter.Adapter):
), ),
) )
def _list(self, resource_type, paginated=True, base_path=None, **attrs): def _list(
self,
resource_type,
paginated=True,
base_path=None,
jmespath_filters=None,
**attrs
):
"""List a resource """List a resource
:param resource_type: The type of resource to list. This should :param resource_type: The type of resource to list. This should
@ -650,6 +658,9 @@ class Proxy(adapter.Adapter):
:param str base_path: Base part of the URI for listing resources, if :param str base_path: Base part of the URI for listing resources, if
different from different from
:data:`~openstack.resource.Resource.base_path`. :data:`~openstack.resource.Resource.base_path`.
:param str jmespath_filters: A string containing a jmespath expression
for further filtering.
:param dict attrs: Attributes to be passed onto the :param dict attrs: Attributes to be passed onto the
:meth:`~openstack.resource.Resource.list` method. These should :meth:`~openstack.resource.Resource.list` method. These should
correspond to either :class:`~openstack.resource.URI` values correspond to either :class:`~openstack.resource.URI` values
@ -660,10 +671,17 @@ class Proxy(adapter.Adapter):
:class:`~openstack.resource.Resource` that doesn't match :class:`~openstack.resource.Resource` that doesn't match
the ``resource_type``. the ``resource_type``.
""" """
return resource_type.list(
self, paginated=paginated, base_path=base_path, **attrs data = resource_type.list(
self, paginated=paginated, base_path=base_path,
**attrs
) )
if jmespath_filters and isinstance(jmespath_filters, str):
return jmespath.search(jmespath_filters, data)
return data
def _head(self, resource_type, value=None, base_path=None, **attrs): def _head(self, resource_type, value=None, base_path=None, **attrs):
"""Retrieve a resource's header """Retrieve a resource's header

View File

@ -1953,6 +1953,9 @@ class Resource(dict):
checked against the :data:`~openstack.resource.Resource.base_path` checked against the :data:`~openstack.resource.Resource.base_path`
format string to see if any path fragments need to be filled in by format string to see if any path fragments need to be filled in by
the contents of this argument. the contents of this argument.
Parameters supported as filters by the server side are passed in
the API call, remaining parameters are applied as filters to the
retrieved results.
:return: A generator of :class:`Resource` objects. :return: A generator of :class:`Resource` objects.
:raises: :exc:`~openstack.exceptions.MethodNotSupported` if :raises: :exc:`~openstack.exceptions.MethodNotSupported` if
@ -1969,12 +1972,24 @@ class Resource(dict):
if base_path is None: if base_path is None:
base_path = cls.base_path base_path = cls.base_path
params = cls._query_mapping._validate( api_filters = cls._query_mapping._validate(
params, params,
base_path=base_path, base_path=base_path,
allow_unknown_params=allow_unknown_params, allow_unknown_params=True,
) )
query_params = cls._query_mapping._transpose(params, cls) client_filters = dict()
# 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), Body)
# not included in the query_params
and k not in cls._query_mapping._mapping.keys()
):
client_filters[k] = v
query_params = cls._query_mapping._transpose(api_filters, cls)
uri = base_path % params uri = base_path % params
uri_params = {} uri_params = {}
@ -1985,6 +2000,18 @@ class Resource(dict):
if hasattr(cls, k) and isinstance(getattr(cls, k), URI): if hasattr(cls, k) and isinstance(getattr(cls, k), URI):
uri_params[k] = v 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
# Track the total number of resources yielded so we can paginate # Track the total number of resources yielded so we can paginate
# swift objects # swift objects
total_yielded = 0 total_yielded = 0
@ -2028,7 +2055,20 @@ class Resource(dict):
**raw_resource, **raw_resource,
) )
marker = value.id marker = value.id
yield value 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
total_yielded += 1 total_yielded += 1
if resources and paginated: if resources and paginated:

View File

@ -43,6 +43,16 @@ class ListableResource(resource.Resource):
allow_list = True allow_list = True
class FilterableResource(resource.Resource):
allow_list = True
base_path = '/fakes'
_query_mapping = resource.QueryParameters('a')
a = resource.Body('a')
b = resource.Body('b')
c = resource.Body('c')
class HeadableResource(resource.Resource): class HeadableResource(resource.Resource):
allow_head = True allow_head = True
@ -461,6 +471,28 @@ class TestProxyList(base.TestCase):
def test_list_override_base_path(self): def test_list_override_base_path(self):
self._test_list(False, base_path='dummy') self._test_list(False, base_path='dummy')
def test_list_filters_jmespath(self):
fake_response = [
FilterableResource(a='a1', b='b1', c='c'),
FilterableResource(a='a2', b='b2', c='c'),
FilterableResource(a='a3', b='b3', c='c'),
]
FilterableResource.list = mock.Mock()
FilterableResource.list.return_value = fake_response
rv = self.sot._list(
FilterableResource, paginated=False,
base_path=None, jmespath_filters="[?c=='c']"
)
self.assertEqual(3, len(rv))
# Test filtering based on unknown attribute
rv = self.sot._list(
FilterableResource, paginated=False,
base_path=None, jmespath_filters="[?d=='c']"
)
self.assertEqual(0, len(rv))
class TestProxyHead(base.TestCase): class TestProxyHead(base.TestCase):

View File

@ -2333,30 +2333,6 @@ class TestResourceActions(base.TestCase):
self.assertEqual(self.session.get.call_args_list[0][0][0], self.assertEqual(self.session.get.call_args_list[0][0][0],
Test.base_path % {"something": uri_param}) Test.base_path % {"something": uri_param})
def test_invalid_list_params(self):
id = 1
qp = "query param!"
qp_name = "query-param"
uri_param = "uri param!"
mock_response = mock.Mock()
mock_response.json.side_effect = [[{"id": id}],
[]]
self.session.get.return_value = mock_response
class Test(self.test_class):
_query_mapping = resource.QueryParameters(query_param=qp_name)
base_path = "/%(something)s/blah"
something = resource.URI("something")
try:
list(Test.list(self.session, paginated=True, query_param=qp,
something=uri_param, something_wrong=True))
self.assertFail('The above line should fail')
except exceptions.InvalidResourceQuery as err:
self.assertEqual(str(err), 'Invalid query params: something_wrong')
def test_allow_invalid_list_params(self): def test_allow_invalid_list_params(self):
qp = "query param!" qp = "query param!"
qp_name = "query-param" qp_name = "query-param"
@ -2384,6 +2360,40 @@ class TestResourceActions(base.TestCase):
params={qp_name: qp} params={qp_name: qp}
) )
def test_list_client_filters(self):
qp = "query param!"
uri_param = "uri param!"
mock_empty = mock.Mock()
mock_empty.status_code = 200
mock_empty.links = {}
mock_empty.json.return_value = {"resources": [
{"a": "1", "b": "1"},
{"a": "1", "b": "2"},
]}
self.session.get.side_effect = [mock_empty]
class Test(self.test_class):
_query_mapping = resource.QueryParameters('a')
base_path = "/%(something)s/blah"
something = resource.URI("something")
a = resource.Body("a")
b = resource.Body("b")
res = list(Test.list(
self.session, paginated=True, query_param=qp,
allow_unknown_params=True, something=uri_param,
a='1', b='2'))
self.session.get.assert_called_once_with(
"/{something}/blah".format(something=uri_param),
headers={'Accept': 'application/json'},
microversion=None,
params={'a': '1'}
)
self.assertEqual(1, len(res))
self.assertEqual("2", res[0].b)
def test_values_as_list_params(self): def test_values_as_list_params(self):
id = 1 id = 1
qp = "query param!" qp = "query param!"