diff --git a/rally/common/utils.py b/rally/common/utils.py index d9246d46c8..de417c0695 100644 --- a/rally/common/utils.py +++ b/rally/common/utils.py @@ -78,6 +78,8 @@ class StdErrCapture(object): class Timer(object): + """Timer based on context manager interface.""" + def __enter__(self): self.error = None self.start = time.time() @@ -86,6 +88,9 @@ class Timer(object): def timestamp(self): return self.start + def finish_timestamp(self): + return self.finish + def __exit__(self, type, value, tb): self.finish = time.time() if type: @@ -652,4 +657,54 @@ def format_float_to_str(num): num_str = "%f" % num float_part = num_str.split(".")[1].rstrip("0") or "0" - return num_str.split(".")[0] + "." + float_part \ No newline at end of file + return num_str.split(".")[0] + "." + float_part + + +class DequeAsQueue(object): + """Allows to use some of Queue methods on collections.deque.""" + + def __init__(self, deque): + self.deque = deque + + def qsize(self): + return len(self.deque) + + def put(self, value): + self.deque.append(value) + + def get(self): + return self.deque.popleft() + + def empty(self): + return bool(self.deque) + + +class Stopwatch(object): + """Allows to sleep till specified time since start.""" + + def __init__(self, stop_event=None): + """Creates Stopwatch. + + :param stop_event: optional threading.Event to use for waiting + allows to interrupt sleep. If not provided time.sleep + will be used instead. + """ + self._stop_event = stop_event + + def start(self): + self._start_time = time.time() + + def sleep(self, sec): + """Sleeps till specified second since start.""" + target_time = self._start_time + sec + current_time = time.time() + if current_time >= target_time: + return + time_to_sleep = target_time - current_time + self._sleep(time_to_sleep) + + def _sleep(self, sec): + if self._stop_event: + self._stop_event.wait(sec) + else: + interruptable_sleep(sec) diff --git a/rally/plugins/common/trigger/__init__.py b/rally/plugins/common/trigger/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rally/plugins/common/trigger/event.py b/rally/plugins/common/trigger/event.py new file mode 100644 index 0000000000..c8bcc7d775 --- /dev/null +++ b/rally/plugins/common/trigger/event.py @@ -0,0 +1,67 @@ +# Copyright 2016: 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. + +from rally import consts +from rally.task import trigger + + +@trigger.configure(name="event") +class EventTrigger(trigger.Trigger): + """Triggers hook on specified event and list of values.""" + + CONFIG_SCHEMA = { + "type": "object", + "$schema": consts.JSON_SCHEMA, + "oneOf": [ + { + "properties": { + "unit": {"enum": ["time"]}, + "at": { + "type": "array", + "minItems": 1, + "uniqueItems": True, + "items": { + "type": "integer", + "minimum": 0, + } + }, + }, + "required": ["unit", "at"], + "additionalProperties": False, + }, + { + "properties": { + "unit": {"enum": ["iteration"]}, + "at": { + "type": "array", + "minItems": 1, + "uniqueItems": True, + "items": { + "type": "integer", + "minimum": 1, + } + }, + }, + "required": ["unit", "at"], + "additionalProperties": False, + }, + ] + } + + def get_configured_event_type(self): + return self.config["unit"] + + def is_runnable(self, value): + return value in self.config["at"] diff --git a/rally/task/trigger.py b/rally/task/trigger.py new file mode 100644 index 0000000000..6c56305c33 --- /dev/null +++ b/rally/task/trigger.py @@ -0,0 +1,45 @@ +# Copyright 2016: 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 abc + +import jsonschema +import six + +from rally.common.plugin import plugin + +configure = plugin.configure + + +@plugin.base() +@six.add_metaclass(abc.ABCMeta) +class Trigger(plugin.Plugin): + """Factory for trigger classes.""" + + @classmethod + def validate(cls, config): + trigger_schema = cls.get(config["name"]).CONFIG_SCHEMA + jsonschema.validate(config["args"], trigger_schema) + + def __init__(self, config): + self.config = config + + @abc.abstractmethod + def get_configured_event_type(self): + """Returns supported event type.""" + + @abc.abstractmethod + def is_runnable(self, value): + """Returns True if trigger is active on specified event value.""" diff --git a/tests/unit/common/test_utils.py b/tests/unit/common/test_utils.py index 9ad5474373..d91aca2b79 100644 --- a/tests/unit/common/test_utils.py +++ b/tests/unit/common/test_utils.py @@ -13,6 +13,7 @@ # under the License. from __future__ import print_function +import collections import string import sys import threading @@ -104,6 +105,8 @@ class TimerTestCase(test.TestCase): mock_time.time = mock.MagicMock(return_value=end_time) self.assertIsNone(timer.error) + self.assertEqual(start_time, timer.timestamp()) + self.assertEqual(end_time, timer.finish_timestamp()) self.assertEqual(end_time - start_time, timer.duration()) def test_timer_exception(self): @@ -586,4 +589,78 @@ class FloatFormatterTestCase(test.TestCase): ) @ddt.unpack def test_format_float_to_str(self, num_float, num_str): - self.assertEquals(num_str, utils.format_float_to_str(num_float)) \ No newline at end of file + self.assertEqual(num_str, utils.format_float_to_str(num_float)) + + +class DequeAsQueueTestCase(test.TestCase): + + def setUp(self): + super(DequeAsQueueTestCase, self).setUp() + self.deque = collections.deque() + self.deque_as_queue = utils.DequeAsQueue(self.deque) + + def test_qsize(self): + self.assertEqual(0, self.deque_as_queue.qsize()) + self.deque.append(10) + self.assertEqual(1, self.deque_as_queue.qsize()) + + def test_put(self): + self.deque_as_queue.put(10) + self.assertEqual(10, self.deque.popleft()) + + def test_get(self): + self.deque.append(33) + self.assertEqual(33, self.deque_as_queue.get()) + + def test_empty(self): + self.assertFalse(self.deque_as_queue.empty()) + self.deque.append(10) + self.assertTrue(self.deque_as_queue.empty()) + + +class StopwatchTestCase(test.TestCase): + + @mock.patch("rally.common.utils.interruptable_sleep") + @mock.patch("rally.common.utils.time") + def test_stopwatch(self, mock_time, mock_interruptable_sleep): + mock_time.time.side_effect = [0, 0, 1, 2, 3] + + sw = utils.Stopwatch() + sw.start() + sw.sleep(1) + sw.sleep(2) + sw.sleep(3) + + mock_interruptable_sleep.assert_has_calls([ + mock.call(1), + mock.call(1), + mock.call(1), + ]) + + @mock.patch("rally.common.utils.interruptable_sleep") + @mock.patch("rally.common.utils.time") + def test_no_sleep(self, mock_time, mock_interruptable_sleep): + mock_time.time.side_effect = [0, 1] + + sw = utils.Stopwatch() + sw.start() + sw.sleep(1) + + self.assertFalse(mock_interruptable_sleep.called) + + @mock.patch("rally.common.utils.time") + def test_stopwatch_with_event(self, mock_time): + mock_time.time.side_effect = [0, 0, 1, 2, 3] + event = mock.Mock(spec=threading.Event)() + + sw = utils.Stopwatch(stop_event=event) + sw.start() + sw.sleep(1) + sw.sleep(2) + sw.sleep(3) + + event.wait.assert_has_calls([ + mock.call(1), + mock.call(1), + mock.call(1), + ]) diff --git a/tests/unit/plugins/common/trigger/__init__.py b/tests/unit/plugins/common/trigger/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/plugins/common/trigger/test_event.py b/tests/unit/plugins/common/trigger/test_event.py new file mode 100644 index 0000000000..b8447b5cb3 --- /dev/null +++ b/tests/unit/plugins/common/trigger/test_event.py @@ -0,0 +1,69 @@ +# Copyright 2016: 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 ddt +import jsonschema + +from rally.task import trigger +from tests.unit import test + + +def create_config(**kwargs): + return {"name": "event", "args": kwargs} + + +@ddt.ddt +class EventTriggerTestCase(test.TestCase): + + def setUp(self): + super(EventTriggerTestCase, self).setUp() + self.trigger = trigger.Trigger.get("event")({"unit": "iteration", + "at": [1, 4, 5]}) + + @ddt.data((create_config(unit="time", at=[0, 3, 5]), True), + (create_config(unit="time", at=[2, 2]), False), + (create_config(unit="time", at=[-1]), False), + (create_config(unit="time", at=[1.5]), False), + (create_config(unit="time", at=[]), False), + (create_config(unit="time", wrong_prop=None), False), + (create_config(unit="time"), False), + (create_config(unit="iteration", at=[1, 5, 13]), True), + (create_config(unit="iteration", at=[1, 1]), False), + (create_config(unit="iteration", at=[0]), False), + (create_config(unit="iteration", at=[-1]), False), + (create_config(unit="iteration", at=[1.5]), False), + (create_config(unit="iteration", at=[]), False), + (create_config(unit="iteration", wrong_prop=None), False), + (create_config(unit="iteration"), False), + (create_config(unit="wrong-unit", at=[1, 2, 3]), False), + (create_config(at=[1, 2, 3]), False)) + @ddt.unpack + def test_config_schema(self, config, valid): + if valid: + trigger.Trigger.validate(config) + else: + self.assertRaises(jsonschema.ValidationError, + trigger.Trigger.validate, config) + + def test_get_configured_event_type(self): + event_type = self.trigger.get_configured_event_type() + self.assertEqual("iteration", event_type) + + @ddt.data((1, True), (4, True), (5, True), + (0, False), (2, False), (3, False), (6, False), (7, False)) + @ddt.unpack + def test_is_runnable(self, value, expected_result): + result = self.trigger.is_runnable(value) + self.assertIs(result, expected_result) diff --git a/tests/unit/task/test_trigger.py b/tests/unit/task/test_trigger.py new file mode 100644 index 0000000000..637ad2b543 --- /dev/null +++ b/tests/unit/task/test_trigger.py @@ -0,0 +1,61 @@ +# Copyright 2016: 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. + +"""Tests for Trigger base class.""" + +import ddt +import jsonschema + +from rally.task import trigger +from tests.unit import test + + +@trigger.configure(name="dummy_trigger") +class DummyTrigger(trigger.Trigger): + CONFIG_SCHEMA = {"type": "integer"} + + def get_configured_event_type(self): + return "dummy" + + def is_runnable(self, value): + return value == self.config + + +@ddt.ddt +class TriggerTestCase(test.TestCase): + + def setUp(self): + super(TriggerTestCase, self).setUp() + self.trigger = DummyTrigger(10) + + @ddt.data(({"name": "dummy_trigger", "args": 5}, True), + ({"name": "dummy_trigger", "args": "str"}, False)) + @ddt.unpack + def test_validate(self, config, valid): + if valid: + trigger.Trigger.validate(config) + else: + self.assertRaises(jsonschema.ValidationError, + trigger.Trigger.validate, config) + + def test_get_configured_event_type(self): + event_type = self.trigger.get_configured_event_type() + self.assertEqual("dummy", event_type) + + @ddt.data((10, True), (1, False)) + @ddt.unpack + def test_is_runnable(self, value, expected_result): + result = self.trigger.is_runnable(value) + self.assertIs(result, expected_result)