From 6f1a895e24dae2afd406a1b679237930ef4ef5ad Mon Sep 17 00:00:00 2001 From: Mikhail Dubov Date: Wed, 25 Jun 2014 13:39:27 +0400 Subject: [PATCH] Add "rally info" command Here we add a new "rally info" command, which, based on the input, prints descriptions for different entities in Rally (in this patch - benchmark scenarios & scenario groups, more entities are to follow (deployment engines etc.)). This command makes use of docstrings by parsing them and printing them in CLI in human-readable form. The command should be called as "rally info find " (or, in case of an explicit parameter setting, "rally info find --query="). Samples: $ rally info find create_meter_and_get_stats CeilometerStats.create_meter_and_get_stats (benchmark scenario). Test creating a meter and fetching its statistics. Meter is first created and then statistics is fetched for the same using GET /v2/meters/(meter_name)/statistics. Parameters: - name_length: length of generated (random) part of meter name - kwargs: contains optional arguments to create a meter $ rally info find Authenticate Authenticate (benchmark scenario group). This class should contain authentication mechanism. For different types of clients like Keystone. Change-Id: Icf3545c0666d99ab7fd0eaabce8bbe572834e485 --- rally/benchmark/scenarios/tempest/utils.py | 2 + rally/cmd/commands/info.py | 81 +++++++++++++ rally/cmd/main.py | 2 + rally/searchutils.py | 64 ++++++++++ rally/utils.py | 112 ++++++++++++++++++ requirements.txt | 1 + test-requirements.txt | 1 - .../benchmark/scenarios/tempest/test_utils.py | 2 + tests/cmd/commands/test_info.py | 50 ++++++++ tests/test_searchutils.py | 54 +++++++++ tests/test_utils.py | 90 ++++++++++++++ 11 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 rally/cmd/commands/info.py create mode 100644 rally/searchutils.py create mode 100644 tests/cmd/commands/test_info.py create mode 100644 tests/test_searchutils.py diff --git a/rally/benchmark/scenarios/tempest/utils.py b/rally/benchmark/scenarios/tempest/utils.py index ec46ddb3e6..25b37a590b 100644 --- a/rally/benchmark/scenarios/tempest/utils.py +++ b/rally/benchmark/scenarios/tempest/utils.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. +import functools import os import subprocess import tempfile @@ -24,6 +25,7 @@ from rally.openstack.common.gettextutils import _ def tempest_log_wrapper(func): + @functools.wraps(func) def inner_func(scenario_obj, *args, **kwargs): if "log_file" not in kwargs: # set temporary log file diff --git a/rally/cmd/commands/info.py b/rally/cmd/commands/info.py new file mode 100644 index 0000000000..43ef6a936a --- /dev/null +++ b/rally/cmd/commands/info.py @@ -0,0 +1,81 @@ +# Copyright 2014: Mirantis Inc. +# All Rights Reserved. +# +# 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. + +""" Rally command: info + +Samples: + + $ rally info find create_meter_and_get_stats + CeilometerStats.create_meter_and_get_stats (benchmark scenario). + + Test creating a meter and fetching its statistics. + + Meter is first created and then statistics is fetched for the same + using GET /v2/meters/(meter_name)/statistics. + Parameters: + - name_length: length of generated (random) part of meter name + - kwargs: contains optional arguments to create a meter + + $ rally info find Authenticate + Authenticate (benchmark scenario group). + + This class should contain authentication mechanism. + + For different types of clients like Keystone. +""" + +from __future__ import print_function + +from rally.cmd import cliutils +from rally import searchutils +from rally import utils + + +class InfoCommands(object): + + @cliutils.args("--query", dest="query", type=str, help="Search query.") + def find(self, query): + """Search for an entity that matches the query and print info about it. + + :param query: search query. + """ + scenario_group = searchutils.find_benchmark_scenario_group(query) + if scenario_group: + print("%s (benchmark scenario group).\n" % scenario_group.__name__) + # TODO(msdubov): Provide all scenario classes with docstrings. + doc = utils.format_docstring(scenario_group.__doc__) + print(doc) + return + + scenario = searchutils.find_benchmark_scenario(query) + if scenario: + print("%(scenario_group)s.%(scenario_name)s " + "(benchmark scenario).\n" % + {"scenario_group": utils.get_method_class(scenario).__name__, + "scenario_name": scenario.__name__}) + doc = utils.parse_docstring(scenario.__doc__) + print(doc["short_description"] + "\n") + if doc["long_description"]: + print(doc["long_description"] + "\n") + if doc["params"]: + print("Parameters:") + for param in doc["params"]: + print(" - %(name)s: %(doc)s" % param) + if doc["returns"]: + print("Returns: %s" % doc["returns"]) + return + + print("Failed to find any docs for query: '%s'" % query) + return 1 diff --git a/rally/cmd/main.py b/rally/cmd/main.py index e94adcb0f8..626d81fa6c 100644 --- a/rally/cmd/main.py +++ b/rally/cmd/main.py @@ -21,6 +21,7 @@ import sys from rally.cmd import cliutils from rally.cmd.commands import deployment +from rally.cmd.commands import info from rally.cmd.commands import show from rally.cmd.commands import task from rally.cmd.commands import use @@ -30,6 +31,7 @@ from rally.cmd.commands import verify def main(): categories = { 'deployment': deployment.DeploymentCommands, + 'info': info.InfoCommands, 'show': show.ShowCommands, 'task': task.TaskCommands, 'use': use.UseCommands, diff --git a/rally/searchutils.py b/rally/searchutils.py new file mode 100644 index 0000000000..792086bb1f --- /dev/null +++ b/rally/searchutils.py @@ -0,0 +1,64 @@ +# Copyright 2014: Mirantis Inc. +# All Rights Reserved. +# +# 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. + +""" Rally entities discovery by queries. """ + +from rally.benchmark.scenarios import base as scenarios_base +from rally import exceptions +from rally import utils + + +def find_benchmark_scenario_group(query): + """Find a scenario class by query. + + :param query: string with the name of the class being searched. + :returns: class object or None if the query doesn't match any + scenario class. + """ + try: + # TODO(msdubov): support approximate string matching + # (here and in other find_* methods). + return scenarios_base.Scenario.get_by_name(query) + except exceptions.NoSuchScenario: + return None + + +def find_benchmark_scenario(query): + """Find a scenario method by query. + + :param query: string with the name of the benchmark scenario being + searched. It can be either a full name (e.g, + 'NovaServers.boot_server') or just a method name (e.g., + 'boot_server') + :returns: method object or None if the query doesn't match any + scenario method. + """ + if "." in query: + scenario_group, scenario_name = query.split(".", 1) + else: + scenario_group = None + scenario_name = query + + if scenario_group: + scenario_cls = find_benchmark_scenario_group(scenario_group) + if hasattr(scenario_cls, scenario_name): + return getattr(scenario_cls, scenario_name) + else: + return None + else: + for scenario_cls in utils.itersubclasses(scenarios_base.Scenario): + if scenario_name in dir(scenario_cls): + return getattr(scenario_cls, scenario_name) + return None diff --git a/rally/utils.py b/rally/utils.py index b1d5a57e3b..33504e696c 100644 --- a/rally/utils.py +++ b/rally/utils.py @@ -15,13 +15,16 @@ import functools import imp +import inspect import itertools import os +import re import StringIO import sys import time import six +from sphinx.util import docstrings from rally import exceptions from rally.openstack.common.gettextutils import _ @@ -193,3 +196,112 @@ def load_plugins(directory): except Exception as e: LOG.error(_("Couldn't load module from %(path)s: %(msg)s") % {"path": fullpath, "msg": six.text_type(e)}) + + +def get_method_class(func): + """Return the class that defined the given method. + + :param func: function to get the class for. + :returns: class object or None if func is not an instance method. + """ + if not hasattr(func, "im_class"): + return None + for cls in inspect.getmro(func.im_class): + if func.__name__ in cls.__dict__: + return cls + return None + + +def first_index(lst, predicate): + """Return the index of the first element that matches a predicate. + + :param lst: list to find the matching element in. + :param predicate: predicate object. + :returns: the index of the first matching element or None if no element + matches the predicate. + """ + for i in range(len(lst)): + if predicate(lst[i]): + return i + return None + + +def format_docstring(docstring): + """Format the docstring to make it well-readable. + + :param docstring: string. + :returns: formatted string. + """ + if docstring: + return "\n".join(docstrings.prepare_docstring(docstring)) + else: + return "" + + +def parse_docstring(docstring): + """Parse the docstring into its components. + + :returns: a dictionary of form + { + "short_description": ..., + "long_description": ..., + "params": [{"name": ..., "doc": ...}, ...], + "returns": ... + } + """ + + if docstring: + docstring_lines = docstrings.prepare_docstring(docstring) + docstring_lines = filter(lambda line: line != "", docstring_lines) + else: + docstring_lines = [] + + if docstring_lines: + + short_description = docstring_lines[0] + + param_lines_start = first_index(docstring_lines, + lambda line: line.startswith(":param") + or line.startswith(":returns")) + if param_lines_start: + long_description = "\n".join(docstring_lines[1:param_lines_start]) + else: + long_description = "\n".join(docstring_lines[1:]) + + if not long_description: + long_description = None + + params = [] + param_regex = re.compile("^:param (?P\w+): (?P.*)$") + for param_line in filter(lambda line: line.startswith(":param"), + docstring_lines): + match = param_regex.match(param_line) + if match: + params.append({ + "name": match.group("name"), + "doc": match.group("doc") + }) + + returns = None + returns_line = filter(lambda line: line.startswith(":returns"), + docstring_lines) + if returns_line: + returns_regex = re.compile("^:returns: (?P.*)$") + match = returns_regex.match(returns_line[0]) + if match: + returns = match.group("doc") + + return { + "short_description": short_description, + "long_description": long_description, + "params": params, + "returns": returns + } + + else: + return { + "short_description": None, + "long_description": None, + "params": [], + "returns": None + } diff --git a/requirements.txt b/requirements.txt index f061bc241a..2f0c5822d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,5 +23,6 @@ python-saharaclient>=0.6.0 python-subunit>=0.0.18 requests>=1.1 SQLAlchemy>=0.8.4,<=0.8.99,>=0.9.7,<=0.9.99 +sphinx>=1.1.2,!=1.2.0,<1.3 six>=1.7.0 WSME>=0.6 diff --git a/test-requirements.txt b/test-requirements.txt index 29c12fdd8a..782342968c 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -6,6 +6,5 @@ mock>=1.0 testrepository>=0.0.18 testtools>=0.9.34 -sphinx>=1.1.2,!=1.2.0,<1.3 oslosphinx oslotest diff --git a/tests/benchmark/scenarios/tempest/test_utils.py b/tests/benchmark/scenarios/tempest/test_utils.py index beb69ebc56..738c561336 100644 --- a/tests/benchmark/scenarios/tempest/test_utils.py +++ b/tests/benchmark/scenarios/tempest/test_utils.py @@ -38,6 +38,7 @@ class TempestLogWrappersTestCase(test.TestCase): def test_launch_without_specified_log_file(self, mock_tmp): mock_tmp.NamedTemporaryFile().name = "tmp_file" target_func = mock.MagicMock() + target_func.__name__ = "target_func" func = utils.tempest_log_wrapper(target_func) func(self.scenario) @@ -48,6 +49,7 @@ class TempestLogWrappersTestCase(test.TestCase): @mock.patch(TS + ".utils.tempfile") def test_launch_with_specified_log_file(self, mock_tmp): target_func = mock.MagicMock() + target_func.__name__ = "target_func" func = utils.tempest_log_wrapper(target_func) func(self.scenario, log_file='log_file') diff --git a/tests/cmd/commands/test_info.py b/tests/cmd/commands/test_info.py new file mode 100644 index 0000000000..b89379d6db --- /dev/null +++ b/tests/cmd/commands/test_info.py @@ -0,0 +1,50 @@ +# Copyright 2013: Mirantis Inc. +# All Rights Reserved. +# +# 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 mock + +from rally.benchmark.scenarios.dummy import dummy +from rally.cmd.commands import info +from tests import test + + +class InfoCommandsTestCase(test.TestCase): + def setUp(self): + super(InfoCommandsTestCase, self).setUp() + self.info = info.InfoCommands() + + @mock.patch("rally.searchutils.find_benchmark_scenario_group") + def test_find_dummy_scenario_group(self, mock_find): + query = "Dummy" + mock_find.return_value = dummy.Dummy + status = self.info.find(query) + mock_find.assert_called_once_with(query) + self.assertEqual(None, status) + + @mock.patch("rally.searchutils.find_benchmark_scenario") + def test_find_dummy_scenario(self, mock_find): + query = "Dummy.dummy" + mock_find.return_value = dummy.Dummy.dummy + status = self.info.find(query) + mock_find.assert_called_once_with(query) + self.assertEqual(None, status) + + @mock.patch("rally.searchutils.find_benchmark_scenario") + def test_find_failure_status(self, mock_find): + query = "Dummy.non_existing" + mock_find.return_value = None + status = self.info.find(query) + mock_find.assert_called_once_with(query) + self.assertEqual(1, status) diff --git a/tests/test_searchutils.py b/tests/test_searchutils.py new file mode 100644 index 0000000000..2f4b9162a8 --- /dev/null +++ b/tests/test_searchutils.py @@ -0,0 +1,54 @@ +# Copyright 2013: Mirantis Inc. +# All Rights Reserved. +# +# 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. + +"""Test for Rally search utils.""" + +from rally.benchmark.scenarios.dummy import dummy +from rally import searchutils +from tests import test + + +class FindBenchmarkScenarioGroupTestCase(test.TestCase): + + def test_find_success(self): + scenario_group = searchutils.find_benchmark_scenario_group("Dummy") + self.assertEqual(scenario_group, dummy.Dummy) + + def test_find_failure(self): + scenario_group = searchutils.find_benchmark_scenario_group("Dumy") + self.assertEqual(scenario_group, None) + + +class FindBenchmarkScenarioTestCase(test.TestCase): + + def test_find_success_full_path(self): + scenario_method = searchutils.find_benchmark_scenario("Dummy.dummy") + self.assertEqual(scenario_method, dummy.Dummy.dummy) + + def test_find_success_shortened_path(self): + scenario_method = searchutils.find_benchmark_scenario("dummy") + self.assertEqual(scenario_method, dummy.Dummy.dummy) + + def test_find_failure_bad_shortening(self): + scenario_method = searchutils.find_benchmark_scenario("dumy") + self.assertEqual(scenario_method, None) + + def test_find_failure_bad_group_name(self): + scenario_method = searchutils.find_benchmark_scenario("Dumy.dummy") + self.assertEqual(scenario_method, None) + + def test_find_failure_bad_scenario_name(self): + scenario_method = searchutils.find_benchmark_scenario("Dummy.dumy") + self.assertEqual(scenario_method, None) diff --git a/tests/test_utils.py b/tests/test_utils.py index cb08e0f569..41c4b423df 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -219,3 +219,93 @@ class LoadExtraModulesTestCase(test.TestCase): # test no fails if module is broken # TODO(olkonami): check exception is handled correct utils.load_plugins("/somwhere") + + +def module_level_method(): + pass + + +class MethodClassTestCase(test.TestCase): + + def test_method_class_for_class_level_method(self): + class A: + def m(self): + pass + self.assertEqual(utils.get_method_class(A.m), A) + + def test_method_class_for_module_level_method(self): + self.assertIsNone(utils.get_method_class(module_level_method)) + + +class FirstIndexTestCase(test.TestCase): + + def test_list_with_existing_matching_element(self): + lst = [1, 3, 5, 7] + self.assertEqual(utils.first_index(lst, lambda e: e == 1), 0) + self.assertEqual(utils.first_index(lst, lambda e: e == 5), 2) + self.assertEqual(utils.first_index(lst, lambda e: e == 7), 3) + + def test_list_with_non_existing_matching_element(self): + lst = [1, 3, 5, 7] + self.assertEqual(utils.first_index(lst, lambda e: e == 2), None) + + +class DocstringTestCase(test.TestCase): + + def test_parse_complete_docstring(self): + docstring = """One-line description. + +Multi- +line- +description. + +:param p1: Param 1 description. +:param p2: Param 2 description. +:returns: Return value description. +""" + + dct = utils.parse_docstring(docstring) + expected = { + "short_description": "One-line description.", + "long_description": "Multi-\nline-\ndescription.", + "params": [{"name": "p1", "doc": "Param 1 description."}, + {"name": "p2", "doc": "Param 2 description."}], + "returns": "Return value description." + } + self.assertEqual(dct, expected) + + def test_parse_incomplete_docstring(self): + docstring = """One-line description. + +:param p1: Param 1 description. +:param p2: Param 2 description. +""" + + dct = utils.parse_docstring(docstring) + expected = { + "short_description": "One-line description.", + "long_description": None, + "params": [{"name": "p1", "doc": "Param 1 description."}, + {"name": "p2", "doc": "Param 2 description."}], + "returns": None + } + self.assertEqual(dct, expected) + + def test_parse_docstring_with_no_params(self): + docstring = """One-line description. + +Multi- +line- +description. + +:returns: Return value description. +""" + + dct = utils.parse_docstring(docstring) + expected = { + "short_description": "One-line description.", + "long_description": "Multi-\nline-\ndescription.", + "params": [], + "returns": "Return value description." + } + self.assertEqual(dct, expected)