diff --git a/esileapclient/common/utils.py b/esileapclient/common/utils.py new file mode 100644 index 0000000..d83ba83 --- /dev/null +++ b/esileapclient/common/utils.py @@ -0,0 +1,72 @@ +import re +import operator +import logging + +# Configure the logger +LOG = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + +OPS = { + '>=': operator.ge, + '<=': operator.le, + '>': operator.gt, + '<': operator.lt, + '=': operator.eq, +} + +operator_pattern = '|'.join(re.escape(op) for op in OPS.keys()) +filter_pattern = re.compile(rf'([^><=]+)({operator_pattern})(.+)') + + +def convert_value(value_str): + """Convert a value string to an appropriate type for comparison.""" + try: + return int(value_str) + except ValueError: + try: + return float(value_str) + except ValueError: + return value_str + + +def parse_property_filter(filter_str): + """Parse a property filter string into a key, operator, and value.""" + match = filter_pattern.match(filter_str) + if not match: + raise ValueError(f"Invalid property filter format: {filter_str}") + key, op_str, value_str = match.groups() + if op_str not in OPS: + raise ValueError(f"Invalid operator in property filter: {op_str}") + value = convert_value(value_str) + return key.strip(), OPS[op_str], value + + +def node_matches_property_filters(node, property_filters): + """Check if a node matches all property filters.""" + for key, op, value in property_filters: + if key not in node['properties']: + return False + node_value = convert_value(node['properties'].get(key, '')) + if not op(node_value, value): + return False + return True + + +def filter_nodes_by_properties(nodes, properties): + """Filter a list of nodes based on property filters.""" + if not properties: + return nodes + property_filters = [] + for prop in properties: + try: + property_filters.append(parse_property_filter(prop)) + except ValueError as e: + LOG.error(f"Error parsing property filter '{prop}': {e}") + raise # Raise the exception to stop execution + + filtered_nodes = [ + node for node in nodes + if node_matches_property_filters(node, property_filters) + ] + + return filtered_nodes diff --git a/esileapclient/osc/v1/node.py b/esileapclient/osc/v1/node.py index 30c42e0..cb2e24e 100644 --- a/esileapclient/osc/v1/node.py +++ b/esileapclient/osc/v1/node.py @@ -16,6 +16,7 @@ from osc_lib import utils as oscutils from esileapclient.v1.node import Node as NODE_RESOURCE +from esileapclient.common import utils LOG = logging.getLogger(__name__) @@ -48,6 +49,14 @@ def get_parser(self, prog_name): dest='lessee', required=False, help="Filter nodes by lessee.") + parser.add_argument( + '--property', + dest='properties', + required=False, + action='append', + help="Filter nodes by properties. Format: key>=value. \ + Can be specified multiple times.", + metavar='"key>=value"') return parser @@ -55,13 +64,20 @@ def take_action(self, parsed_args): client = self.app.client_manager.lease + # Initial filters dictionary filters = { 'resource_class': parsed_args.resource_class, 'owner': parsed_args.owner, 'lessee': parsed_args.lessee } - data = list(client.nodes(**filters)) + # Retrieve all nodes with initial filters + all_nodes = list(client.nodes(**filters)) + + # Apply filtering based on properties + filtered_nodes = utils.filter_nodes_by_properties( + all_nodes, parsed_args.properties + ) if parsed_args.long: columns = NODE_RESOURCE.detailed_fields.keys() @@ -72,4 +88,4 @@ def take_action(self, parsed_args): return (labels, (oscutils.get_item_properties(s, columns) - for s in data)) + for s in filtered_nodes)) diff --git a/esileapclient/tests/unit/common/test_utils.py b/esileapclient/tests/unit/common/test_utils.py new file mode 100644 index 0000000..aee26f3 --- /dev/null +++ b/esileapclient/tests/unit/common/test_utils.py @@ -0,0 +1,78 @@ +import unittest +from esileapclient.common import utils + + +class TestUtils(unittest.TestCase): + + def setUp(self): + self.nodes = [ + {'properties': {'cpus': '40', 'memory_mb': '131072'}}, + {'properties': {'cpus': '80', 'memory_mb': '262144'}}, + {'properties': {'cpus': '20', 'memory_mb': '65536'}}, + ] + + def test_convert_value(self): + self.assertEqual(utils.convert_value('10'), 10) + self.assertEqual(utils.convert_value('10.5'), 10.5) + self.assertEqual(utils.convert_value('text'), 'text') + + def test_parse_property_filter(self): + key, op, value = utils.parse_property_filter('cpus>=40') + self.assertEqual(key, 'cpus') + self.assertEqual(op, utils.OPS['>=']) + self.assertEqual(value, 40) + + key, op, value = utils.parse_property_filter('memory_mb<=131072') + self.assertEqual(key, 'memory_mb') + self.assertEqual(op, utils.OPS['<=']) + self.assertEqual(value, 131072) + + with self.assertRaises(ValueError): + utils.parse_property_filter('invalid_filter') + + with self.assertRaises(ValueError): + utils.parse_property_filter('cpus!40') + + def test_node_matches_property_filters(self): + filters = [ + utils.parse_property_filter('cpus>=40'), + utils.parse_property_filter('memory_mb>=131072') + ] + self.assertTrue(utils.node_matches_property_filters( + self.nodes[1], filters)) + self.assertFalse(utils.node_matches_property_filters( + self.nodes[2], filters)) + + filters = [utils.parse_property_filter('non_existent_property>=100')] + self.assertFalse(utils.node_matches_property_filters( + self.nodes[0], filters)) + + def test_filter_nodes_by_properties(self): + properties = ['cpus>=40'] + filtered_nodes = utils.filter_nodes_by_properties( + self.nodes, properties) + self.assertEqual(len(filtered_nodes), 2) + + properties = ['memory_mb<=131072'] + filtered_nodes = utils.filter_nodes_by_properties( + self.nodes, properties) + self.assertEqual(len(filtered_nodes), 2) + + properties = ['cpus>100'] + filtered_nodes = utils.filter_nodes_by_properties( + self.nodes, properties) + self.assertEqual(len(filtered_nodes), 0) + + properties = ['cpus<40'] + filtered_nodes = utils.filter_nodes_by_properties( + self.nodes, properties) + self.assertEqual(len(filtered_nodes), 1) + self.assertEqual(filtered_nodes[0]['properties']['cpus'], '20') + + properties = ['invalid_filter'] + with self.assertLogs('esileapclient.common.utils', level='ERROR') as c: + with self.assertRaises(ValueError): + utils.filter_nodes_by_properties(self.nodes, properties) + self.assertTrue(any( + "Error parsing property filter 'invalid_filter'" in message + for message in c.output)) diff --git a/esileapclient/tests/unit/osc/v1/fakes.py b/esileapclient/tests/unit/osc/v1/fakes.py index 593f88b..eb1d760 100644 --- a/esileapclient/tests/unit/osc/v1/fakes.py +++ b/esileapclient/tests/unit/osc/v1/fakes.py @@ -43,7 +43,16 @@ event_type = 'fake.event' event_time = "3000-07-01T12" object_type = 'lease' -node_properties = {'cpu': '40', 'traits': ['trait1', 'trait2']} +node_properties = { + 'cpus': '40', + 'memory_mb': '131072', + 'local_gb': '1200', + 'cpu_arch': 'x86_64', + 'vendor': 'fake-vendor', + 'cpu_model_name': 'fake-model', + 'cpu_frequency': '2000.0', + 'traits': ['trait1', 'trait2'] + } OFFER = { 'availabilities': json.loads(lease_availabilities), diff --git a/esileapclient/tests/unit/osc/v1/test_node.py b/esileapclient/tests/unit/osc/v1/test_node.py index 66c80f1..6cf0d76 100644 --- a/esileapclient/tests/unit/osc/v1/test_node.py +++ b/esileapclient/tests/unit/osc/v1/test_node.py @@ -11,6 +11,7 @@ # under the License. import copy +from unittest import mock from esileapclient.osc.v1 import node from esileapclient.tests.unit.osc.v1 import base @@ -125,3 +126,37 @@ def test_node_list_long(self): '', '', '', '' ),) self.assertEqual(datalist, tuple(data)) + + @mock.patch('esileapclient.common.utils.filter_nodes_by_properties') + def test_node_list_with_property_filter(self, mock_filter_nodes): + arglist = ['--property', 'cpus>=40'] + verifylist = [('properties', ['cpus>=40'])] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + + filters = { + 'resource_class': parsed_args.resource_class, + 'owner': parsed_args.owner, + 'lessee': parsed_args.lessee + } + + self.client_mock.nodes.assert_called_with(**filters) + mock_filter_nodes.assert_called_with(mock.ANY, parsed_args.properties) + + @mock.patch('esileapclient.common.utils.filter_nodes_by_properties') + def test_node_list_long_with_property_filter(self, mock_filter_nodes): + arglist = ['--long', '--property', 'memory_mb>=131072'] + verifylist = [('long', True), ('properties', ['memory_mb>=131072'])] + + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + columns, data = self.cmd.take_action(parsed_args) + + filters = { + 'resource_class': parsed_args.resource_class, + 'owner': parsed_args.owner, + 'lessee': parsed_args.lessee + } + + self.client_mock.nodes.assert_called_with(**filters) + mock_filter_nodes.assert_called_with(mock.ANY, parsed_args.properties)