From 463179581843481de4b28de671c7fb6377ca2c26 Mon Sep 17 00:00:00 2001 From: Federico Ressi Date: Tue, 16 Apr 2019 12:03:20 +0200 Subject: [PATCH] Refactor exceptions message formatting on top of format method This updates also some doctstrings related to exception handling Change-Id: If9903e66f187b837e238cb5fcdd8cc8e819f22b6 --- tobiko/cmd/create.py | 2 +- tobiko/common/_asserts.py | 19 +++++- tobiko/common/_exception.py | 47 +++++++++++---- tobiko/openstack/heat/_stack.py | 8 +-- tobiko/openstack/keystone/_credentials.py | 2 +- tobiko/shell/ping/_exception.py | 10 ++-- tobiko/shell/sh/_exception.py | 12 ++-- tobiko/tests/test_assert.py | 38 ++++++++++++ tobiko/tests/test_exception.py | 73 +++++++++++++++++++++++ 9 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 tobiko/tests/test_assert.py create mode 100644 tobiko/tests/test_exception.py diff --git a/tobiko/cmd/create.py b/tobiko/cmd/create.py index 20d17f086..fc7e1c89d 100644 --- a/tobiko/cmd/create.py +++ b/tobiko/cmd/create.py @@ -39,7 +39,7 @@ class CreateUtil(base.TobikoCMD): class NoSuchTemplateError(tobiko.TobikoException): - message = "No such template. Existing templates:\n%(templates)s" + message = "no such template; existing templates are: {templates}" def main(): diff --git a/tobiko/common/_asserts.py b/tobiko/common/_asserts.py index 76fd2f30b..fa3b6045e 100644 --- a/tobiko/common/_asserts.py +++ b/tobiko/common/_asserts.py @@ -19,7 +19,20 @@ import testtools FailureException = testtools.TestCase.failureException -def fail(reason, *args, **kwargs): +def fail(msg, *args, **kwargs): + """Fail immediately current test case execution, with the given message. + + Unconditionally raises a tobiko.FailureException as in below equivalent + code: + + raise FailureException(msg.format(*args, **kwargs)) + + :param msg: string message used to create FailureException + :param *args: positional arguments to be passed to str.format method + :param **kwargs: key-word arguments to be passed to str.format method + :returns: It never returns + :raises FailureException: + """ if args or kwargs: - reason = reason.format(*args, **kwargs) - raise FailureException(reason) + msg = msg.format(*args, **kwargs) + raise FailureException(msg) diff --git a/tobiko/common/_exception.py b/tobiko/common/_exception.py index 6ed182e96..fb138fbc2 100644 --- a/tobiko/common/_exception.py +++ b/tobiko/common/_exception.py @@ -17,22 +17,40 @@ from __future__ import absolute_import class TobikoException(Exception): """Base Tobiko Exception. - To use this class, inherit from it and define a 'message' property. + To use this class, inherit from it and define attribute 'message' string. + If **properties parameters is given, then it will format message string + using properties as key-word arguments. + + Example: + + class MyException(TobikoException): + message = "This exception occurred because of {reason}" + + try: + raise MyException(reason="something went wrong") + except MyException as ex: + + # It should print: + # This exception occurred because of something went wrong + print(ex) + + # It should print: + # something went wrong + print(ex.reason) + + :attribute message: the message to be printed out. """ message = None - def __init__(self, **properties): - super(TobikoException, self).__init__() - self._properties = properties - message = self.message # pylint: disable=exception-message-attribute - if message: - if properties: - message = message % properties - self._message = message or "unknown reason" - - def __str__(self): - return self._message + def __init__(self, message=None, **properties): + # pylint: disable=exception-message-attribute + message = message or self.message or "unknown reason" + if properties: + message = message.format(**properties) + self.message = message + self._properties = properties or {} + super(TobikoException, self).__init__(message) def __getattr__(self, name): try: @@ -40,3 +58,8 @@ class TobikoException(Exception): except KeyError: msg = ("{!r} object has no attribute {!r}").format(self, name) raise AttributeError(msg) + + def __repr__(self): + return "{class_name}({message!r})".format( + class_name=type(self).__name__, + message=self.message) diff --git a/tobiko/openstack/heat/_stack.py b/tobiko/openstack/heat/_stack.py index b5ed945c8..47f4b9423 100644 --- a/tobiko/openstack/heat/_stack.py +++ b/tobiko/openstack/heat/_stack.py @@ -301,16 +301,16 @@ def check_stack_status(stack, expected): class InvalidHeatStackOutputKey(tobiko.TobikoException): - message = "Output key %(key)r not found in stack %(name)r." + message = "output key {key!r} not found in stack {name!r}" class HeatStackNotFound(tobiko.TobikoException): - message = "Stack %(name)r not found" + message = "stack {name!r} not found" class InvalidHeatStackStatus(tobiko.TobikoException): - message = ("Stack %(name)r status %(observed)r not in %(expected)r.\n" - "%(status_reason)s") + message = ("stack {name!r} status {observed!r} not in {expected!r}\n" + "{status_reason!s}") class HeatStackCreationFailed(InvalidHeatStackStatus): diff --git a/tobiko/openstack/keystone/_credentials.py b/tobiko/openstack/keystone/_credentials.py index 7d68c0154..1534e6ade 100644 --- a/tobiko/openstack/keystone/_credentials.py +++ b/tobiko/openstack/keystone/_credentials.py @@ -74,7 +74,7 @@ def keystone_credentials(api_version=None, auth_url=None, class InvalidKeystoneCredentials(tobiko.TobikoException): - message = "Invalid Keystone credentials (%(credentials)r): %(reason)s." + message = "invalid Keystone credentials; {reason!s}; {credentials!r}" class EnvironKeystoneCredentialsFixture(tobiko.SharedFixture): diff --git a/tobiko/shell/ping/_exception.py b/tobiko/shell/ping/_exception.py index 514070d91..d4a2daab4 100644 --- a/tobiko/shell/ping/_exception.py +++ b/tobiko/shell/ping/_exception.py @@ -24,7 +24,7 @@ class PingException(tobiko.TobikoException): class PingError(PingException): """Base ping error""" - message = ("%(details)s") + message = "{details!s}" class LocalPingError(PingError): @@ -41,13 +41,13 @@ class UnknowHostError(PingError): class BadAddressPingError(PingError): """Raised when passing wrong address to ping command""" - message = ("Bad address: %(address)r") + message = "bad address ({address!r})" class PingFailed(PingError, tobiko.FailureException): """Raised when ping timeout expires before reaching expected message count """ - message = ("Timeout of %(timeout)d seconds expired after counting only " - "%(count)d out of expected %(expected_count)d ICMP messages of " - "type %(message_type)r.") + message = ("timeout of {timeout} seconds expired after counting only " + "{count} out of expected {expected_count} ICMP messages of " + "type {message_type!r}") diff --git a/tobiko/shell/sh/_exception.py b/tobiko/shell/sh/_exception.py index 8ae98e346..06753cb82 100644 --- a/tobiko/shell/sh/_exception.py +++ b/tobiko/shell/sh/_exception.py @@ -25,14 +25,14 @@ class ShellError(tobiko.TobikoException): class ShellCommandFailed(ShellError): """Raised when shell command exited with non-zero status """ - message = ("Command %(command)r failed, exit status: %(exit_status)d, " - "stderr:\n%(stderr)s\n" - "stdout:\n%(stdout)s") + message = ("command {command!r} failed (exit status is {exit_status}); " + "stderr:\n{stderr!s}\n" + "stdout:\n{stdout!s}") class ShellTimeoutExpired(ShellError): """Raised when shell command timeouts and has been killed before exiting """ - message = ("Command '%(command)s' timed out: %(timeout)d, " - "stderr:\n%(stderr)s\n" - "stdout:\n%(stdout)s") + message = ("command {command!r} timed out after {timeout!s} seconds; " + "stderr:\n{stderr!s}\n" + "stdout:\n{stdout!s}") diff --git a/tobiko/tests/test_assert.py b/tobiko/tests/test_assert.py new file mode 100644 index 000000000..e01c08131 --- /dev/null +++ b/tobiko/tests/test_assert.py @@ -0,0 +1,38 @@ +# Copyright 2019 Red Hat +# +# 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 __future__ import absolute_import + +import tobiko +from tobiko.tests import unit + + +class TestFail(unit.TobikoUnitTest): + + def test_fail(self): + self._test_fail('some_reason') + + def test_fail_with_args(self): + self._test_fail('some {1!r} {0!s}', 'reason', 'strange') + + def test_fail_with_kwargs(self): + self._test_fail('some {b!r} {a!s}', a='reason', b='strange') + + def _test_fail(self, reason, *args, **kwargs): + ex = self.assertRaises( + tobiko.FailureException, tobiko.fail, reason, *args, **kwargs) + if args or kwargs: + expected_reason = reason.format(*args, **kwargs) + else: + expected_reason = reason + self.assertEqual(expected_reason, str(ex)) diff --git a/tobiko/tests/test_exception.py b/tobiko/tests/test_exception.py new file mode 100644 index 000000000..2cb2a8648 --- /dev/null +++ b/tobiko/tests/test_exception.py @@ -0,0 +1,73 @@ +# Copyright 2019 Red Hat +# +# 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 __future__ import absolute_import + +import tobiko +from tobiko.tests import unit + + +class SomeException(tobiko.TobikoException): + message = "message formatted with {b} and {a}" + + +class TestException(unit.TobikoUnitTest): + + def test_init(self, message=None, **properties): + ex = SomeException(message, **properties) + expected_str = message or SomeException.message + if properties: + expected_str = expected_str.format(**properties) + self.assertEqual(expected_str, str(ex)) + for k, v in properties.items(): + self.assertEqual(v, getattr(ex, k)) + + def test_init_with_properties(self): + self.test_init(a='a', b='b') + + def test_init_with_message(self): + self.test_init('{other} message', other='another') + + def test_raise(self): + def _raise_my_exception(**kwargs): + raise SomeException(**kwargs) + ex = self.assertRaises(SomeException, _raise_my_exception, b=1, a=2) + self.assertEqual('message formatted with 1 and 2', str(ex)) + + def test_repr(self): + ex = SomeException('some reason') + self.assertEqual("SomeException('some reason')", repr(ex)) + + def test_get_invalid_property(self): + ex = SomeException(a='1', b='2') + + def _get_invalid_property(): + return ex.invalid_attribute_name + + ex = self.assertRaises(AttributeError, _get_invalid_property) + self.assertEqual( + "SomeException('message formatted with 2 and 1') object has no " + "attribute 'invalid_attribute_name'", str(ex)) + + def test_docstring_example(self): + + class MyException(tobiko.TobikoException): + message = "This exception occurred because of {reason}" + + try: + raise MyException(reason="something went wrong") + except MyException as ex: + self.assertEqual( + 'This exception occurred because of something went wrong', + str(ex)) + self.assertEqual('something went wrong', ex.reason)