From c32993fa9dd8a5fb442bebc49a827bd864f91c5f Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Thu, 15 Oct 2026 10:23:14 -0400 Subject: [PATCH 1/2] update exception handling --- cterasdk/exceptions/base.py | 18 ++--- cterasdk/exceptions/transport.py | 4 + .../UserGuides/Miscellaneous/Changelog.rst | 5 +- .../UserGuides/Miscellaneous/Exceptions.rst | 3 - tests/ut/core/admin/test_users.py | 80 ------------------- 5 files changed, 12 insertions(+), 98 deletions(-) diff --git a/cterasdk/exceptions/base.py b/cterasdk/exceptions/base.py index 08bdba9f..4d91c1dd 100644 --- a/cterasdk/exceptions/base.py +++ b/cterasdk/exceptions/base.py @@ -1,9 +1,12 @@ +from abc import abstractmethod + + class CTERAException(Exception): """ Base Exception. :param str message: Error message - :ivar str reason: Portal error message from :attr:`~BaseException.__cause__` when it is an HTTP transport error + :ivar str reason: Server error message """ def __init__(self, message=None, **kwargs): super().__init__(message) @@ -11,18 +14,9 @@ def __init__(self, message=None, **kwargs): setattr(self, k, v) @property + @abstractmethod def reason(self): - cause = self.__cause__ - if cause is None: - return None - error_object = getattr(cause, 'error', None) - if error_object is None: - return None - response = getattr(error_object, 'response', None) - portal_error = getattr(response, 'error', None) if response else None - if portal_error is None or isinstance(portal_error, str): - return None - return getattr(portal_error, 'msg', None) + return self.__cause__.reason if self.__cause__ and hasattr(self.__cause__, 'reason') else None def __repr__(self): return str(self) diff --git a/cterasdk/exceptions/transport.py b/cterasdk/exceptions/transport.py index ada6ffbb..f904f2bd 100644 --- a/cterasdk/exceptions/transport.py +++ b/cterasdk/exceptions/transport.py @@ -17,6 +17,10 @@ def __init__(self, status, error): self.name = status.name self.error = error + @property + def reason(self): + return getattr(self.error.response.error, 'msg', None) + class BadRequest(HTTPError): diff --git a/docs/source/UserGuides/Miscellaneous/Changelog.rst b/docs/source/UserGuides/Miscellaneous/Changelog.rst index e12cf442..631378a4 100644 --- a/docs/source/UserGuides/Miscellaneous/Changelog.rst +++ b/docs/source/UserGuides/Miscellaneous/Changelog.rst @@ -7,10 +7,9 @@ Changelog Bug Fixes ^^^^^^^^^ -* Expose portal validation details via :attr:`~cterasdk.exceptions.CTERAException.reason` (for - example, when ``admins.modify`` fails because the new password matches the current password). +* Add a ``reason`` property to :py:class:`~cterasdk.exceptions.base.CTERAException`. -Related issue: `SDK-369 `_ +Related issues and pull requests on GitHub: `#374 `_ 2.20.49 ------- diff --git a/docs/source/UserGuides/Miscellaneous/Exceptions.rst b/docs/source/UserGuides/Miscellaneous/Exceptions.rst index a991dc58..9cd4fd39 100644 --- a/docs/source/UserGuides/Miscellaneous/Exceptions.rst +++ b/docs/source/UserGuides/Miscellaneous/Exceptions.rst @@ -6,9 +6,6 @@ Exceptions :members: :show-inheritance: -:attr:`~cterasdk.exceptions.CTERAException.reason` returns the portal ``msg`` from -:attr:`~BaseException.__cause__` when the cause is an HTTP transport error. - .. autoclass:: cterasdk.exceptions.ObjectNotFoundException :noindex: :members: diff --git a/tests/ut/core/admin/test_users.py b/tests/ut/core/admin/test_users.py index 38bf628a..1caace86 100644 --- a/tests/ut/core/admin/test_users.py +++ b/tests/ut/core/admin/test_users.py @@ -319,86 +319,6 @@ def test_modify_admin_user(self): actual_param = self._global_admin.api.put.call_args[0][1] self._assert_equal_objects(actual_param, new_user_object) - def test_modify_admin_user_same_password_exposes_portal_reason(self): - current_user_object = self._get_admin_object( - name=self._username, - email=self._email, - firstName=self._first_name, - lastName=self._last_name, - password=self._password, - role=self._role, - company=None, - comment=None - ) - portal_msg = 'Object validation failed (field: password error: Old and new passwords cannot be the same)' - password_value = 'Str0ngP@ssword!1' - portal_error = Object() - portal_error.msg = portal_msg - field = Object() - field.name = 'password' - field.desc = 'Old and new passwords cannot be the same' - field.val = password_value - portal_error.fields = [field] - http_error = Object() - http_error.request = Object(url='/administrators/' + self._username) - http_error.response = Object(status=500, error=portal_error) - self._init_global_admin(get_response=current_user_object) - self._global_admin.api.put = mock.MagicMock(side_effect=exceptions.transport.InternalServerError(http_error)) - with self.assertRaises(exceptions.CTERAException) as error: - admins.Administrators(self._global_admin).modify(self._username, password=self._password) - ref = f'/administrators/{self._username}' - self._global_admin.api.get.assert_called_once_with(ref) - self._global_admin.api.put.assert_called_once_with(ref, mock.ANY) - self.assertEqual(f'Could not modify user: {ref}', str(error.exception)) - self.assertIn('Old and new passwords cannot be the same', error.exception.reason) - self.assertNotIn(password_value, error.exception.reason) - - def test_modify_admin_user_http_error_without_portal_msg_uses_generic_message(self): - current_user_object = self._get_admin_object( - name=self._username, - email=self._email, - firstName=self._first_name, - lastName=self._last_name, - password=self._password, - role=self._role, - company=None, - comment=None - ) - portal_error = Object() - http_error = Object() - http_error.request = Object(url='/administrators/' + self._username) - http_error.response = Object(status=500, error=portal_error) - self._init_global_admin(get_response=current_user_object) - self._global_admin.api.put = mock.MagicMock(side_effect=exceptions.transport.InternalServerError(http_error)) - ref = f'/administrators/{self._username}' - expected_message = f'Could not modify user: {ref}' - with self.assertRaises(exceptions.CTERAException) as error: - admins.Administrators(self._global_admin).modify(self._username, password=self._password) - self.assertEqual(expected_message, str(error.exception)) - self.assertIsNone(error.exception.reason) - - def test_modify_admin_user_non_http_error_uses_generic_message(self): - current_user_object = self._get_admin_object( - name=self._username, - email=self._email, - firstName=self._first_name, - lastName=self._last_name, - password=self._password, - role=self._role, - company=None, - comment=None - ) - self._init_global_admin(get_response=current_user_object) - self._global_admin.api.put = mock.MagicMock( - side_effect=exceptions.ObjectNotFoundException('Object not found') - ) - ref = f'/administrators/{self._username}' - expected_message = f'Could not modify user: {ref}' - with self.assertRaises(exceptions.CTERAException) as error: - admins.Administrators(self._global_admin).modify(self._username, password=self._password) - self.assertEqual(expected_message, str(error.exception)) - self.assertIsNone(error.exception.reason) - def test_delete_admin_user(self): execute_response = 'Success' self._init_global_admin(execute_response=execute_response) From d4ae14cb8df99b7236f9283970301b0354cd805c Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Thu, 15 Oct 2026 10:32:12 -0400 Subject: [PATCH 2/2] update ut --- tests/ut/exceptions/test_base.py | 65 ++++++++++---------------------- 1 file changed, 19 insertions(+), 46 deletions(-) diff --git a/tests/ut/exceptions/test_base.py b/tests/ut/exceptions/test_base.py index 5877cf2d..6f9b39e6 100644 --- a/tests/ut/exceptions/test_base.py +++ b/tests/ut/exceptions/test_base.py @@ -1,49 +1,22 @@ -from cterasdk.common import Object -from cterasdk.exceptions import CTERAException -from cterasdk.exceptions.transport import InternalServerError +import munch +from unittest import mock +from cterasdk import exceptions +from cterasdk.core import admins from tests.ut import base -class TestCTERAExceptionReason(base.BaseTest): - - @staticmethod - def _internal_server_error(portal_error): - http_error = Object() - http_error.request = Object(url='https://example/admin/api/administrators/alice') - http_error.response = Object(status=500, error=portal_error) - return InternalServerError(http_error) - - def test_reason_returns_portal_msg_from_http_cause(self): - portal_msg = 'Object validation failed (field: password error: Old and new passwords cannot be the same)' - portal_error = Object() - portal_error.msg = portal_msg - try: - raise CTERAException('Could not modify user: /administrators/alice') from self._internal_server_error( - portal_error - ) - except CTERAException as error: - self.assertEqual(portal_msg, error.reason) - - def test_reason_is_none_without_cause(self): - self.assertIsNone(CTERAException('Failed').reason) - - def test_reason_is_none_when_cause_is_not_http_transport_error(self): - try: - raise CTERAException('Failed') from ValueError('not transport') - except CTERAException as error: - self.assertIsNone(error.reason) - - def test_reason_is_none_when_portal_error_has_no_msg(self): - try: - raise CTERAException('Failed') from self._internal_server_error(Object()) - except CTERAException as error: - self.assertIsNone(error.reason) - - def test_reason_is_none_when_response_error_is_plain_string(self): - http_error = Object() - http_error.request = Object(url='https://example/admin') - http_error.response = Object(status=500, error='Internal Server Error') - try: - raise CTERAException('Failed') from InternalServerError(http_error) - except CTERAException as error: - self.assertIsNone(error.reason) +class TestExceptions(base.BaseTest): + + def test_reason(self): + error_message = 'error message' + error = munch.Munch({ + 'response': munch.Munch({ + 'error': munch.Munch({ + 'msg': error_message + }) + }) + }) + self._global_admin.api.get = mock.MagicMock(side_effect=exceptions.transport.InternalServerError(error)) + with self.assertRaises(exceptions.CTERAException) as error: + admins.Administrators(self._global_admin).modify('user', password='password') + self.assertEqual(error_message, error.exception.reason)