From c5e6df9ec86a313d85c06e3a9e973f3fd9380602 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Mon, 11 Nov 2024 20:35:54 +0200 Subject: [PATCH] chore: Returns all the errors at ones instead of raising early. Improve tests Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 137 ++++++++++++++---------- src/cloudevents/core/v1/exceptions.py | 27 +++++ tests/test_core/test_v1/test_event.py | 144 +++++++++++--------------- 3 files changed, 171 insertions(+), 137 deletions(-) create mode 100644 src/cloudevents/core/v1/exceptions.py diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index 2d82168..be5a262 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -12,17 +12,19 @@ # License for the specific language governing permissions and limitations # under the License. -from typing import Any, Optional, Final -from datetime import datetime import re +from datetime import datetime +from typing import Any, Final, Optional -REQUIRED_ATTRIBUTES: Final[set[str]] = {"id", "source", "type", "specversion"} -OPTIONAL_ATTRIBUTES: Final[set[str]] = { +from cloudevents.core.v1.exceptions import CloudEventValidationError + +REQUIRED_ATTRIBUTES: Final[list[str]] = ["id", "source", "type", "specversion"] +OPTIONAL_ATTRIBUTES: Final[list[str]] = [ "datacontenttype", "dataschema", "subject", "time", -} +] class CloudEvent: @@ -55,102 +57,129 @@ class CloudEvent: See https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#required-attributes """ - CloudEvent._validate_required_attributes(attributes) - CloudEvent._validate_attribute_types(attributes) - CloudEvent._validate_optional_attributes(attributes) - CloudEvent._validate_extension_attributes(attributes) + errors = {} + errors.update(CloudEvent._validate_required_attributes(attributes)) + errors.update(CloudEvent._validate_attribute_types(attributes)) + errors.update(CloudEvent._validate_optional_attributes(attributes)) + errors.update(CloudEvent._validate_extension_attributes(attributes)) + if errors: + raise CloudEventValidationError(errors) @staticmethod - def _validate_required_attributes(attributes: dict[str, Any]) -> None: + def _validate_required_attributes( + attributes: dict[str, Any], + ) -> dict[str, list[str]]: """ Validates that all required attributes are present. :param attributes: The attributes of the CloudEvent instance. - :raises ValueError: If any of the required attributes are missing. + :return: A dictionary of validation error messages. """ + errors = {} missing_attributes = [ attr for attr in REQUIRED_ATTRIBUTES if attr not in attributes ] if missing_attributes: - raise ValueError( + errors["required"] = [ f"Missing required attribute(s): {', '.join(missing_attributes)}" - ) + ] + return errors @staticmethod - def _validate_attribute_types(attributes: dict[str, Any]) -> None: + def _validate_attribute_types(attributes: dict[str, Any]) -> dict[str, list[str]]: """ Validates the types of the required attributes. :param attributes: The attributes of the CloudEvent instance. - :raises ValueError: If any of the required attributes have invalid values. - :raises TypeError: If any of the required attributes have invalid types. + :return: A dictionary of validation error messages. """ - if attributes["id"] is None: - raise ValueError("Attribute 'id' must not be None") - if not isinstance(attributes["id"], str): - raise TypeError("Attribute 'id' must be a string") - if not isinstance(attributes["source"], str): - raise TypeError("Attribute 'source' must be a string") - if not isinstance(attributes["type"], str): - raise TypeError("Attribute 'type' must be a string") - if not isinstance(attributes["specversion"], str): - raise TypeError("Attribute 'specversion' must be a string") - if attributes["specversion"] != "1.0": - raise ValueError("Attribute 'specversion' must be '1.0'") + errors = {} + type_errors = [] + if attributes.get("id") is None: + type_errors.append("Attribute 'id' must not be None") + if not isinstance(attributes.get("id"), str): + type_errors.append("Attribute 'id' must be a string") + if not isinstance(attributes.get("source"), str): + type_errors.append("Attribute 'source' must be a string") + if not isinstance(attributes.get("type"), str): + type_errors.append("Attribute 'type' must be a string") + if not isinstance(attributes.get("specversion"), str): + type_errors.append("Attribute 'specversion' must be a string") + if attributes.get("specversion") != "1.0": + type_errors.append("Attribute 'specversion' must be '1.0'") + if type_errors: + errors["type"] = type_errors + return errors @staticmethod - def _validate_optional_attributes(attributes: dict[str, Any]) -> None: + def _validate_optional_attributes( + attributes: dict[str, Any], + ) -> dict[str, list[str]]: """ Validates the types and values of the optional attributes. :param attributes: The attributes of the CloudEvent instance. - :raises ValueError: If any of the optional attributes have invalid values. - :raises TypeError: If any of the optional attributes have invalid types. + :return: A dictionary of validation error messages. """ + errors = {} + optional_errors = [] if "time" in attributes: if not isinstance(attributes["time"], datetime): - raise TypeError("Attribute 'time' must be a datetime object") - if not attributes["time"].tzinfo: - raise ValueError("Attribute 'time' must be timezone aware") + optional_errors.append("Attribute 'time' must be a datetime object") + if hasattr(attributes["time"], "tzinfo") and not attributes["time"].tzinfo: + optional_errors.append("Attribute 'time' must be timezone aware") if "subject" in attributes: if not isinstance(attributes["subject"], str): - raise TypeError("Attribute 'subject' must be a string") + optional_errors.append("Attribute 'subject' must be a string") if not attributes["subject"]: - raise ValueError("Attribute 'subject' must not be empty") + optional_errors.append("Attribute 'subject' must not be empty") if "datacontenttype" in attributes: if not isinstance(attributes["datacontenttype"], str): - raise TypeError("Attribute 'datacontenttype' must be a string") + optional_errors.append("Attribute 'datacontenttype' must be a string") if not attributes["datacontenttype"]: - raise ValueError("Attribute 'datacontenttype' must not be empty") + optional_errors.append("Attribute 'datacontenttype' must not be empty") if "dataschema" in attributes: if not isinstance(attributes["dataschema"], str): - raise TypeError("Attribute 'dataschema' must be a string") + optional_errors.append("Attribute 'dataschema' must be a string") if not attributes["dataschema"]: - raise ValueError("Attribute 'dataschema' must not be empty") + optional_errors.append("Attribute 'dataschema' must not be empty") + if optional_errors: + errors["optional"] = optional_errors + return errors @staticmethod - def _validate_extension_attributes(attributes: dict[str, Any]) -> None: + def _validate_extension_attributes( + attributes: dict[str, Any], + ) -> dict[str, list[str]]: """ Validates the extension attributes. :param attributes: The attributes of the CloudEvent instance. - :raises ValueError: If any of the extension attributes have invalid values. + :return: A dictionary of validation error messages. """ - for extension_attributes in ( - set(attributes.keys()) - REQUIRED_ATTRIBUTES - OPTIONAL_ATTRIBUTES - ): - if extension_attributes == "data": - raise ValueError( + errors = {} + extension_errors = [] + extension_attributes = [ + key + for key in attributes.keys() + if key not in REQUIRED_ATTRIBUTES and key not in OPTIONAL_ATTRIBUTES + ] + for extension_attribute in extension_attributes: + if extension_attribute == "data": + extension_errors.append( "Extension attribute 'data' is reserved and must not be used" ) - if not (1 <= len(extension_attributes) <= 20): - raise ValueError( - f"Extension attribute '{extension_attributes}' should be between 1 and 20 characters long" + if not (1 <= len(extension_attribute) <= 20): + extension_errors.append( + f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long" ) - if not re.match(r"^[a-z0-9]+$", extension_attributes): - raise ValueError( - f"Extension attribute '{extension_attributes}' should only contain lowercase letters and numbers" + if not re.match(r"^[a-z0-9]+$", extension_attribute): + extension_errors.append( + f"Extension attribute '{extension_attribute}' should only contain lowercase letters and numbers" ) + if extension_errors: + errors["extensions"] = extension_errors + return errors def get_id(self) -> str: """ @@ -215,7 +244,7 @@ class CloudEvent: :return: The time of the event. """ return self._attributes.get("time") - + def get_extension(self, extension_name: str) -> Any: """ Retrieve an extension attribute of the event. diff --git a/src/cloudevents/core/v1/exceptions.py b/src/cloudevents/core/v1/exceptions.py new file mode 100644 index 0000000..cae226a --- /dev/null +++ b/src/cloudevents/core/v1/exceptions.py @@ -0,0 +1,27 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# 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. +class CloudEventValidationError(Exception): + """ + Custom exception for validation errors. + """ + + def __init__(self, errors: dict[str, list[str]]) -> None: + super().__init__("Validation errors occurred") + self.errors = errors + + def __str__(self) -> str: + error_messages = [ + f"{key}: {', '.join(value)}" for key, value in self.errors.items() + ] + return f"{super().__str__()}: {', '.join(error_messages)}" diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py index bc20079..a6ca1cd 100644 --- a/tests/test_core/test_v1/test_event.py +++ b/tests/test_core/test_v1/test_event.py @@ -12,79 +12,48 @@ # License for the specific language governing permissions and limitations # under the License. -from cloudevents.core.v1.event import CloudEvent +from datetime import datetime, timezone +from typing import Any import pytest -from datetime import datetime, timezone -from typing import Any, Optional + +from cloudevents.core.v1.event import CloudEvent +from cloudevents.core.v1.exceptions import CloudEventValidationError -@pytest.mark.parametrize( - "attributes, missing_attribute", - [ - ({"source": "/", "type": "test", "specversion": "1.0"}, "id"), - ({"id": "1", "type": "test", "specversion": "1.0"}, "source"), - ({"id": "1", "source": "/", "specversion": "1.0"}, "type"), - ({"id": "1", "source": "/", "type": "test"}, "specversion"), - ], -) -def test_missing_required_attribute(attributes: dict, missing_attribute: str) -> None: - with pytest.raises(ValueError) as e: - CloudEvent(attributes) +def test_missing_required_attributes() -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent({}) - assert str(e.value) == f"Missing required attribute(s): {missing_attribute}" - - -@pytest.mark.parametrize( - "id,error", - [ - (None, "Attribute 'id' must not be None"), - (12, "Attribute 'id' must be a string"), - ], -) -def test_id_validation(id: Optional[Any], error: str) -> None: - with pytest.raises((ValueError, TypeError)) as e: - CloudEvent({"id": id, "source": "/", "type": "test", "specversion": "1.0"}) - - assert str(e.value) == error - - -@pytest.mark.parametrize("source,error", [(123, "Attribute 'source' must be a string")]) -def test_source_validation(source: Any, error: str) -> None: - with pytest.raises((ValueError, TypeError)) as e: - CloudEvent({"id": "1", "source": source, "type": "test", "specversion": "1.0"}) - - assert str(e.value) == error - - -@pytest.mark.parametrize( - "specversion,error", - [ - (1.0, "Attribute 'specversion' must be a string"), - ("1.4", "Attribute 'specversion' must be '1.0'"), - ], -) -def test_specversion_validation(specversion: Any, error: str) -> None: - with pytest.raises((ValueError, TypeError)) as e: - CloudEvent( - {"id": "1", "source": "/", "type": "test", "specversion": specversion} - ) - - assert str(e.value) == error + assert e.value.errors == { + "required": ["Missing required attribute(s): id, source, type, specversion"], + "type": [ + "Attribute 'id' must not be None", + "Attribute 'id' must be a string", + "Attribute 'source' must be a string", + "Attribute 'type' must be a string", + "Attribute 'specversion' must be a string", + "Attribute 'specversion' must be '1.0'", + ], + } @pytest.mark.parametrize( "time,error", [ - ("2023-10-25T17:09:19.736166Z", "Attribute 'time' must be a datetime object"), + ( + "2023-10-25T17:09:19.736166Z", + {"optional": ["Attribute 'time' must be a datetime object"]}, + ), ( datetime(2023, 10, 25, 17, 9, 19, 736166), - "Attribute 'time' must be timezone aware", + {"optional": ["Attribute 'time' must be timezone aware"]}, ), + (1, {"optional": ["Attribute 'time' must be a datetime object"]}), ], ) -def test_time_validation(time: Any, error: str) -> None: - with pytest.raises((ValueError, TypeError)) as e: +def test_time_validation(time: Any, error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: CloudEvent( { "id": "1", @@ -95,21 +64,21 @@ def test_time_validation(time: Any, error: str) -> None: } ) - assert str(e.value) == error + assert e.value.errors == error @pytest.mark.parametrize( "subject,error", [ - (1234, "Attribute 'subject' must be a string"), + (1234, {"optional": ["Attribute 'subject' must be a string"]}), ( "", - "Attribute 'subject' must not be empty", + {"optional": ["Attribute 'subject' must not be empty"]}, ), ], ) -def test_subject_validation(subject: Any, error: str) -> None: - with pytest.raises((ValueError, TypeError)) as e: +def test_subject_validation(subject: Any, error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: CloudEvent( { "id": "1", @@ -120,21 +89,21 @@ def test_subject_validation(subject: Any, error: str) -> None: } ) - assert str(e.value) == error + assert e.value.errors == error @pytest.mark.parametrize( "datacontenttype,error", [ - (1234, "Attribute 'datacontenttype' must be a string"), + (1234, {"optional": ["Attribute 'datacontenttype' must be a string"]}), ( "", - "Attribute 'datacontenttype' must not be empty", + {"optional": ["Attribute 'datacontenttype' must not be empty"]}, ), ], ) -def test_datacontenttype_validation(datacontenttype: Any, error: str) -> None: - with pytest.raises((ValueError, TypeError)) as e: +def test_datacontenttype_validation(datacontenttype: Any, error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: CloudEvent( { "id": "1", @@ -145,21 +114,21 @@ def test_datacontenttype_validation(datacontenttype: Any, error: str) -> None: } ) - assert str(e.value) == error + assert e.value.errors == error @pytest.mark.parametrize( "dataschema,error", [ - (1234, "Attribute 'dataschema' must be a string"), + (1234, {"optional": ["Attribute 'dataschema' must be a string"]}), ( "", - "Attribute 'dataschema' must not be empty", + {"optional": ["Attribute 'dataschema' must not be empty"]}, ), ], ) def test_dataschema_validation(dataschema: Any, error: str) -> None: - with pytest.raises((ValueError, TypeError)) as e: + with pytest.raises(CloudEventValidationError) as e: CloudEvent( { "id": "1", @@ -170,7 +139,7 @@ def test_dataschema_validation(dataschema: Any, error: str) -> None: } ) - assert str(e.value) == error + assert e.value.errors == error @pytest.mark.parametrize( @@ -178,24 +147,33 @@ def test_dataschema_validation(dataschema: Any, error: str) -> None: [ ( "", - "Extension attribute '' should be between 1 and 20 characters long", + { + "extensions": [ + "Extension attribute '' should be between 1 and 20 characters long", + "Extension attribute '' should only contain lowercase letters and numbers", + ] + }, ), ( "thisisaverylongextension", - "Extension attribute 'thisisaverylongextension' should be between 1 and 20 characters long", - ), - ( - "ThisIsNotValid", - "Extension attribute 'ThisIsNotValid' should only contain lowercase letters and numbers", + { + "extensions": [ + "Extension attribute 'thisisaverylongextension' should be between 1 and 20 characters long" + ] + }, ), ( "data", - "Extension attribute 'data' is reserved and must not be used", + { + "extensions": [ + "Extension attribute 'data' is reserved and must not be used" + ] + }, ), ], ) -def test_custom_extension(extension_name: str, error: str) -> None: - with pytest.raises(ValueError) as e: +def test_custom_extension(extension_name: str, error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: CloudEvent( { "id": "1", @@ -206,7 +184,7 @@ def test_custom_extension(extension_name: str, error: str) -> None: } ) - assert str(e.value) == error + assert e.value.errors == error def test_cloud_event_constructor() -> None: