diff --git a/gnocchiclient/tests/functional/test_resource.py b/gnocchiclient/tests/functional/test_resource.py index 6f0fcd1..8d3ce6e 100644 --- a/gnocchiclient/tests/functional/test_resource.py +++ b/gnocchiclient/tests/functional/test_resource.py @@ -63,7 +63,7 @@ class ResourceClientTest(base.ClientTestBase): self.assertEqual(self.PROJECT_ID, resource_updated["project_id"]) self.assertEqual(resource["started_at"], resource_updated["started_at"]) - self.assertEqual("temperature", resource["metrics"].keys()[0]) + self.assertIn("temperature", resource_updated["metrics"]) # GET result = self.gnocchi( @@ -83,6 +83,15 @@ class ResourceClientTest(base.ClientTestBase): self.assertEqual(self.PROJECT_ID, resource_list["project_id"]) self.assertEqual(resource["started_at"], resource_list["started_at"]) + # Search + result = self.gnocchi('resource', + params="search generic --query 'project_id=%s'" % + self.PROJECT_ID) + resource_list = self.parser.listing(result)[0] + self.assertEqual(self.RESOURCE_ID, resource_list["id"]) + self.assertEqual(self.PROJECT_ID, resource_list["project_id"]) + self.assertEqual(resource["started_at"], resource_list["started_at"]) + # DELETE result = self.gnocchi('resource', params="delete %s" % self.RESOURCE_ID) diff --git a/gnocchiclient/tests/unit/test_utils.py b/gnocchiclient/tests/unit/test_utils.py new file mode 100644 index 0000000..dfd07fd --- /dev/null +++ b/gnocchiclient/tests/unit/test_utils.py @@ -0,0 +1,83 @@ +# -*- encoding: utf-8 -*- +# +# 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 oslotest import base + +from gnocchiclient import utils + + +class SearchQueryBuilderTest(base.BaseTestCase): + def _do_test(self, expr, expected): + req = utils.search_query_builder(expr) + self.assertEqual(expected, req) + + def test_search_query_builder(self): + self._do_test('foo=bar', {"=": {"foo": "bar"}}) + self._do_test('foo!=1', {"!=": {"foo": 1.0}}) + self._do_test('foo=True', {"=": {"foo": True}}) + self._do_test('foo=null', {"=": {"foo": None}}) + self._do_test('foo="null"', {"=": {"foo": "null"}}) + self._do_test('foo in ["null", "foo"]', + {"in": {"foo": ["null", "foo"]}}) + self._do_test(u'foo="quote" and bar≠1', + {"and": [{u"≠": {"bar": 1}}, + {"=": {"foo": "quote"}}]}) + self._do_test('foo="quote" or bar like "%%foo"', + {"or": [{"like": {"bar": "%%foo"}}, + {"=": {"foo": "quote"}}]}) + + self._do_test('not (foo="quote" or bar like "%%foo" or foo="what!" ' + 'or bar="who?")', + {"not": {"or": [ + {"=": {"bar": "who?"}}, + {"=": {"foo": "what!"}}, + {"like": {"bar": "%%foo"}}, + {"=": {"foo": "quote"}}, + ]}}) + + self._do_test('(foo="quote" or bar like "%%foo" or not foo="what!" ' + 'or bar="who?") and cat="meme"', + {"and": [ + {"=": {"cat": "meme"}}, + {"or": [ + {"=": {"bar": "who?"}}, + {"not": {"=": {"foo": "what!"}}}, + {"like": {"bar": "%%foo"}}, + {"=": {"foo": "quote"}}, + ]} + ]}) + + self._do_test('foo="quote" or bar like "%%foo" or foo="what!" ' + 'or bar="who?" and cat="meme"', + {"or": [ + {"and": [ + {"=": {"cat": "meme"}}, + {"=": {"bar": "who?"}}, + ]}, + {"=": {"foo": "what!"}}, + {"like": {"bar": "%%foo"}}, + {"=": {"foo": "quote"}}, + ]}) + + self._do_test('foo="quote" or bar like "%%foo" and foo="what!" ' + 'or bar="who?" or cat="meme"', + {"or": [ + {"=": {"cat": "meme"}}, + {"=": {"bar": "who?"}}, + {"and": [ + {"=": {"foo": "what!"}}, + {"like": {"bar": "%%foo"}}, + ]}, + {"=": {"foo": "quote"}}, + ]}) diff --git a/gnocchiclient/utils.py b/gnocchiclient/utils.py new file mode 100644 index 0000000..03c93cc --- /dev/null +++ b/gnocchiclient/utils.py @@ -0,0 +1,85 @@ +# -*- encoding: utf-8 -*- +# +# 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 pyparsing as pp + +uninary_operators = ("not", ) +binary_operator = (u">=", u"<=", u"!=", u">", u"<", u"=", u"==", u"eq", u"ne", + u"lt", u"gt", u"ge", u"le", u"in", u"like", u"≠", u"≥", + u"≤", u"like" "in") +multiple_operators = (u"and", u"or", u"∧", u"∨") + +operator = pp.Regex(u"|".join(binary_operator)) +null = pp.Regex("None|none|null").setParseAction(pp.replaceWith(None)) +boolean = "False|True|false|true" +boolean = pp.Regex(boolean).setParseAction(lambda t: t[0].lower() == "true") +hex_string = lambda n: pp.Word(pp.hexnums, exact=n) +uuid = pp.Combine(hex_string(8) + ("-" + hex_string(4)) * 3 + + "-" + hex_string(12)) +number = r"[+-]?\d+(:?\.\d*)?(:?[eE][+-]?\d+)?" +number = pp.Regex(number).setParseAction(lambda t: float(t[0])) +identifier = pp.Word(pp.alphas, pp.alphanums + "_") +quoted_string = pp.QuotedString('"') +comparison_term = pp.Forward() +in_list = pp.Group(pp.Suppress('[') + + pp.Optional(pp.delimitedList(comparison_term)) + + pp.Suppress(']'))("list") +comparison_term << (null | boolean | uuid | identifier | number | + quoted_string | in_list) +condition = pp.Group(comparison_term + operator + comparison_term) + +expr = pp.operatorPrecedence(condition, [ + ("not", 1, pp.opAssoc.RIGHT, ), + ("and", 2, pp.opAssoc.LEFT, ), + ("∧", 2, pp.opAssoc.LEFT, ), + ("or", 2, pp.opAssoc.LEFT, ), + ("∨", 2, pp.opAssoc.LEFT, ), +]) + + +def _parsed_query2dict(parsed_query): + result = None + while parsed_query: + part = parsed_query.pop() + if part in binary_operator: + result = {part: {parsed_query.pop(): result}} + + elif part in multiple_operators: + if result.get(part): + result[part].append( + _parsed_query2dict(parsed_query.pop())) + else: + result = {part: [result]} + + elif part in uninary_operators: + result = {part: result} + elif isinstance(part, pp.ParseResults): + kind = part.getName() + if kind == "list": + res = part.asList() + else: + res = _parsed_query2dict(part) + if result is None: + result = res + elif isinstance(result, dict): + result.values()[0].append(res) + else: + result = part + return result + + +def search_query_builder(query): + parsed_query = expr.parseString(query)[0] + return _parsed_query2dict(parsed_query) diff --git a/gnocchiclient/v1/resource.py b/gnocchiclient/v1/resource.py index 3a7edc2..13af5d5 100644 --- a/gnocchiclient/v1/resource.py +++ b/gnocchiclient/v1/resource.py @@ -20,6 +20,7 @@ from cliff import lister from cliff import show from oslo_serialization import jsonutils +from gnocchiclient import utils from gnocchiclient.v1 import base @@ -52,6 +53,17 @@ class ResourceManager(base.Manager): url = self.client.url("resource/generic/%s" % (resource_id)) self.client.api.delete(url) + def search(self, resource_type="generic", details=False, history=False, + request=None): + request = request or {} + details = "true" if details else "false" + history = "true" if history else "false" + url = self.client.url("/search/resource/%s?details=%s&history=%s" % ( + resource_type, details, history)) + return self.client.api.post( + url, headers={'Content-Type': "application/json"}, + data=jsonutils.dumps(request)).json() + class CliResourceList(lister.Lister): COLS = ('id', 'type', @@ -83,6 +95,22 @@ class CliResourceList(lister.Lister): return tuple([resource[k] for k in cls.COLS]) +class CliResourceSearch(CliResourceList): + def get_parser(self, prog_name): + parser = super(CliResourceSearch, self).get_parser(prog_name) + parser.add_argument("-q", "--query", + help="Query"), + return parser + + def take_action(self, parsed_args): + resources = self.app.client.resource.search( + resource_type=parsed_args.resource_type, + details=parsed_args.details, + history=parsed_args.history, + request=utils.search_query_builder(parsed_args.query)) + return self.COLS, [self._resource2tuple(r) for r in resources] + + def normalize_metrics(res): res['metrics'] = "\n".join(sorted( ["%s: %s" % (name, _id) @@ -131,7 +159,7 @@ class CliResourceCreate(show.ShowOne): attr, __, value = attr.partition(":") resource[attr] = value if parsed_args.metric: - rid = getattr(parsed_args, 'resource_id') + rid = getattr(parsed_args, 'resource_id', None) if rid: r = self.app.client.resource.get(parsed_args.resource_type, parsed_args.resource_id) diff --git a/setup.cfg b/setup.cfg index b898315..52b4874 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,7 @@ console_scripts = gnocchi.cli.v1 = resource_list = gnocchiclient.v1.resource:CliResourceList resource_show = gnocchiclient.v1.resource:CliResourceShow + resource_search = gnocchiclient.v1.resource:CliResourceSearch resource_create = gnocchiclient.v1.resource:CliResourceCreate resource_update = gnocchiclient.v1.resource:CliResourceUpdate resource_delete = gnocchiclient.v1.resource:CliResourceDelete