From 21493e180fee71707d5d5db127a27645c0a468f3 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Thu, 14 Nov 2024 10:47:17 +0200 Subject: [PATCH] chore: Improve exceptions and introduce a new one for invalid values Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 73 ++++++++++----------- src/cloudevents/core/v1/exceptions.py | 38 ++++++++--- tests/test_core/test_v1/test_event.py | 92 ++++++++++----------------- 3 files changed, 97 insertions(+), 106 deletions(-) diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index ace1960..5114aac 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -22,6 +22,7 @@ from cloudevents.core.v1.exceptions import ( CloudEventValidationError, CustomExtensionAttributeError, InvalidAttributeTypeError, + InvalidAttributeValueError, MissingRequiredAttributeError, ) @@ -84,41 +85,37 @@ class CloudEvent: errors = defaultdict(list) if "id" not in attributes: - errors["id"].append(MissingRequiredAttributeError(missing="id")) + errors["id"].append(MissingRequiredAttributeError(attribute_name="id")) if attributes.get("id") is None: errors["id"].append( - InvalidAttributeTypeError("Attribute 'id' must not be None") + InvalidAttributeValueError("id", "Attribute 'id' must not be None") ) if not isinstance(attributes.get("id"), str): - errors["id"].append( - InvalidAttributeTypeError("Attribute 'id' must be a string") - ) + errors["id"].append(InvalidAttributeTypeError("id", str)) if "source" not in attributes: - errors["source"].append(MissingRequiredAttributeError(missing="source")) - if not isinstance(attributes.get("source"), str): errors["source"].append( - InvalidAttributeTypeError("Attribute 'source' must be a string") + MissingRequiredAttributeError(attribute_name="source") ) + if not isinstance(attributes.get("source"), str): + errors["source"].append(InvalidAttributeTypeError("source", str)) if "type" not in attributes: - errors["type"].append(MissingRequiredAttributeError(missing="type")) + errors["type"].append(MissingRequiredAttributeError(attribute_name="type")) if not isinstance(attributes.get("type"), str): - errors["type"].append( - InvalidAttributeTypeError("Attribute 'type' must be a string") - ) + errors["type"].append(InvalidAttributeTypeError("type", str)) if "specversion" not in attributes: errors["specversion"].append( - MissingRequiredAttributeError(missing="specversion") + MissingRequiredAttributeError(attribute_name="specversion") ) if not isinstance(attributes.get("specversion"), str): - errors["specversion"].append( - InvalidAttributeTypeError("Attribute 'specversion' must be a string") - ) + errors["specversion"].append(InvalidAttributeTypeError("specversion", str)) if attributes.get("specversion") != "1.0": errors["specversion"].append( - InvalidAttributeTypeError("Attribute 'specversion' must be '1.0'") + InvalidAttributeValueError( + "specversion", "Attribute 'specversion' must be '1.0'" + ) ) return errors @@ -136,46 +133,43 @@ class CloudEvent: if "time" in attributes: if not isinstance(attributes["time"], datetime): - errors["time"].append( - InvalidAttributeTypeError( - "Attribute 'time' must be a datetime object" - ) - ) + errors["time"].append(InvalidAttributeTypeError("time", datetime)) if hasattr(attributes["time"], "tzinfo") and not attributes["time"].tzinfo: errors["time"].append( - InvalidAttributeTypeError("Attribute 'time' must be timezone aware") + InvalidAttributeValueError( + "time", "Attribute 'time' must be timezone aware" + ) ) if "subject" in attributes: if not isinstance(attributes["subject"], str): - errors["subject"].append( - InvalidAttributeTypeError("Attribute 'subject' must be a string") - ) + errors["subject"].append(InvalidAttributeTypeError("subject", str)) if not attributes["subject"]: errors["subject"].append( - InvalidAttributeTypeError("Attribute 'subject' must not be empty") + InvalidAttributeValueError( + "subject", "Attribute 'subject' must not be empty" + ) ) if "datacontenttype" in attributes: if not isinstance(attributes["datacontenttype"], str): errors["datacontenttype"].append( - InvalidAttributeTypeError( - "Attribute 'datacontenttype' must be a string" - ) + InvalidAttributeTypeError("datacontenttype", str) ) if not attributes["datacontenttype"]: errors["datacontenttype"].append( - InvalidAttributeTypeError( - "Attribute 'datacontenttype' must not be empty" + InvalidAttributeValueError( + "datacontenttype", + "Attribute 'datacontenttype' must not be empty", ) ) if "dataschema" in attributes: if not isinstance(attributes["dataschema"], str): errors["dataschema"].append( - InvalidAttributeTypeError("Attribute 'dataschema' must be a string") + InvalidAttributeTypeError("dataschema", str) ) if not attributes["dataschema"]: errors["dataschema"].append( - InvalidAttributeTypeError( - "Attribute 'dataschema' must not be empty" + InvalidAttributeValueError( + "dataschema", "Attribute 'dataschema' must not be empty" ) ) return errors @@ -200,19 +194,22 @@ class CloudEvent: if extension_attribute == "data": errors[extension_attribute].append( CustomExtensionAttributeError( - "Extension attribute 'data' is reserved and must not be used" + extension_attribute, + "Extension attribute 'data' is reserved and must not be used", ) ) if not (1 <= len(extension_attribute) <= 20): errors[extension_attribute].append( CustomExtensionAttributeError( - f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long" + extension_attribute, + f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long", ) ) if not re.match(r"^[a-z0-9]+$", extension_attribute): errors[extension_attribute].append( CustomExtensionAttributeError( - f"Extension attribute '{extension_attribute}' should only contain lowercase letters and numbers" + extension_attribute, + f"Extension attribute '{extension_attribute}' should only contain lowercase letters and numbers", ) ) return errors diff --git a/src/cloudevents/core/v1/exceptions.py b/src/cloudevents/core/v1/exceptions.py index 0d8680d..539c031 100644 --- a/src/cloudevents/core/v1/exceptions.py +++ b/src/cloudevents/core/v1/exceptions.py @@ -12,6 +12,8 @@ # License for the specific language governing permissions and limitations # under the License. class BaseCloudEventException(Exception): + """A CloudEvent generic exception.""" + pass @@ -25,7 +27,7 @@ class CloudEventValidationError(BaseCloudEventException): :param errors: The errors gathered during the CloudEvent creation where key is the name of the attribute and value is a list of errors related to that attribute. """ - super().__init__("Validation errors occurred") + super().__init__("Failed to create CloudEvent due to the validation errors:") self.errors: dict[str, list[BaseCloudEventException]] = errors def __str__(self) -> str: @@ -35,22 +37,40 @@ class CloudEventValidationError(BaseCloudEventException): return f"{super().__str__()}: {', '.join(error_messages)}" -class MissingRequiredAttributeError(BaseCloudEventException): +class MissingRequiredAttributeError(BaseCloudEventException, ValueError): """ - Exception for missing required attribute. + Raised for attributes that are required to be present by the specification. """ - def __init__(self, missing: str) -> None: - super().__init__(f"Missing required attribute: '{missing}'") + def __init__(self, attribute_name: str) -> None: + super().__init__(f"Missing required attribute: '{attribute_name}'") -class CustomExtensionAttributeError(BaseCloudEventException): +class CustomExtensionAttributeError(BaseCloudEventException, ValueError): """ - Exception for invalid custom extension names. + Raised when a custom extension attribute violates naming conventions. """ + def __init__(self, extension_attribute: str, msg: str) -> None: + self.extension_attribute = extension_attribute + super().__init__(msg) -class InvalidAttributeTypeError(BaseCloudEventException): + +class InvalidAttributeTypeError(BaseCloudEventException, TypeError): """ - Exception for invalid attribute type. + Raised when an attribute has an unsupported type. """ + + def __init__(self, attribute_name: str, expected_type: type) -> None: + self.attribute_name = attribute_name + super().__init__(f"Attribute '{attribute_name}' must be a {expected_type}") + + +class InvalidAttributeValueError(BaseCloudEventException, ValueError): + """ + Raised when an attribute has an invalid value. + """ + + def __init__(self, attribute_name: str, msg: str) -> None: + self.attribute_name = attribute_name + super().__init__(msg) diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py index 1ee9886..acd3fd2 100644 --- a/tests/test_core/test_v1/test_event.py +++ b/tests/test_core/test_v1/test_event.py @@ -22,6 +22,7 @@ from cloudevents.core.v1.exceptions import ( CloudEventValidationError, CustomExtensionAttributeError, InvalidAttributeTypeError, + InvalidAttributeValueError, MissingRequiredAttributeError, ) @@ -33,21 +34,25 @@ def test_missing_required_attributes() -> None: expected_errors = { "id": [ str(MissingRequiredAttributeError("id")), - str(InvalidAttributeTypeError("Attribute 'id' must not be None")), - str(InvalidAttributeTypeError("Attribute 'id' must be a string")), + str(InvalidAttributeValueError("id", "Attribute 'id' must not be None")), + str(InvalidAttributeTypeError("id", str)), ], "source": [ str(MissingRequiredAttributeError("source")), - str(InvalidAttributeTypeError("Attribute 'source' must be a string")), + str(InvalidAttributeTypeError("source", str)), ], "type": [ str(MissingRequiredAttributeError("type")), - str(InvalidAttributeTypeError("Attribute 'type' must be a string")), + str(InvalidAttributeTypeError("type", str)), ], "specversion": [ str(MissingRequiredAttributeError("specversion")), - str(InvalidAttributeTypeError("Attribute 'specversion' must be a string")), - str(InvalidAttributeTypeError("Attribute 'specversion' must be '1.0'")), + str(InvalidAttributeTypeError("specversion", str)), + str( + InvalidAttributeValueError( + "specversion", "Attribute 'specversion' must be '1.0'" + ) + ), ], } @@ -62,23 +67,15 @@ def test_missing_required_attributes() -> None: [ ( "2023-10-25T17:09:19.736166Z", - { - "time": [ - str( - InvalidAttributeTypeError( - "Attribute 'time' must be a datetime object" - ) - ) - ] - }, + {"time": [str(InvalidAttributeTypeError("time", datetime))]}, ), ( datetime(2023, 10, 25, 17, 9, 19, 736166), { "time": [ str( - InvalidAttributeTypeError( - "Attribute 'time' must be timezone aware" + InvalidAttributeValueError( + "time", "Attribute 'time' must be timezone aware" ) ) ] @@ -86,15 +83,7 @@ def test_missing_required_attributes() -> None: ), ( 1, - { - "time": [ - str( - InvalidAttributeTypeError( - "Attribute 'time' must be a datetime object" - ) - ) - ] - }, + {"time": [str(InvalidAttributeTypeError("time", datetime))]}, ), ], ) @@ -120,23 +109,15 @@ def test_time_validation(time: Any, expected_error: dict) -> None: [ ( 1234, - { - "subject": [ - str( - InvalidAttributeTypeError( - "Attribute 'subject' must be a string" - ) - ) - ] - }, + {"subject": [str(InvalidAttributeTypeError("subject", str))]}, ), ( "", { "subject": [ str( - InvalidAttributeTypeError( - "Attribute 'subject' must not be empty" + InvalidAttributeValueError( + "subject", "Attribute 'subject' must not be empty" ) ) ] @@ -169,11 +150,7 @@ def test_subject_validation(subject: Any, expected_error: dict) -> None: 1234, { "datacontenttype": [ - str( - InvalidAttributeTypeError( - "Attribute 'datacontenttype' must be a string" - ) - ) + str(InvalidAttributeTypeError("datacontenttype", str)) ] }, ), @@ -182,8 +159,9 @@ def test_subject_validation(subject: Any, expected_error: dict) -> None: { "datacontenttype": [ str( - InvalidAttributeTypeError( - "Attribute 'datacontenttype' must not be empty" + InvalidAttributeValueError( + "datacontenttype", + "Attribute 'datacontenttype' must not be empty", ) ) ] @@ -214,23 +192,15 @@ def test_datacontenttype_validation(datacontenttype: Any, expected_error: dict) [ ( 1234, - { - "dataschema": [ - str( - InvalidAttributeTypeError( - "Attribute 'dataschema' must be a string" - ) - ) - ] - }, + {"dataschema": [str(InvalidAttributeTypeError("dataschema", str))]}, ), ( "", { "dataschema": [ str( - InvalidAttributeTypeError( - "Attribute 'dataschema' must not be empty" + InvalidAttributeValueError( + "dataschema", "Attribute 'dataschema' must not be empty" ) ) ] @@ -265,12 +235,14 @@ def test_dataschema_validation(dataschema: Any, expected_error: dict) -> None: "": [ str( CustomExtensionAttributeError( - "Extension attribute '' should be between 1 and 20 characters long" + "", + "Extension attribute '' should be between 1 and 20 characters long", ) ), str( CustomExtensionAttributeError( - "Extension attribute '' should only contain lowercase letters and numbers" + "", + "Extension attribute '' should only contain lowercase letters and numbers", ) ), ] @@ -282,7 +254,8 @@ def test_dataschema_validation(dataschema: Any, expected_error: dict) -> None: "thisisaverylongextension": [ str( CustomExtensionAttributeError( - "Extension attribute 'thisisaverylongextension' should be between 1 and 20 characters long" + "thisisaverylongextension", + "Extension attribute 'thisisaverylongextension' should be between 1 and 20 characters long", ) ) ] @@ -294,7 +267,8 @@ def test_dataschema_validation(dataschema: Any, expected_error: dict) -> None: "data": [ str( CustomExtensionAttributeError( - "Extension attribute 'data' is reserved and must not be used" + "data", + "Extension attribute 'data' is reserved and must not be used", ) ) ]