From a2ac76224b07ee7924fb9ea987db4f05bdc1c03c Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Fri, 8 Nov 2024 15:10:53 +0200 Subject: [PATCH 01/20] feat: base `CloudEvent` class as per v1 specs, including attribute validation Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/__init__.py | 0 src/cloudevents/core/v1/event.py | 71 ++++++++++++ tests/test_core/__init__.py | 0 tests/test_core/test_v1/__init__.py | 0 tests/test_core/test_v1/test_event.py | 158 ++++++++++++++++++++++++++ 5 files changed, 229 insertions(+) create mode 100644 src/cloudevents/core/v1/__init__.py create mode 100644 src/cloudevents/core/v1/event.py create mode 100644 tests/test_core/__init__.py create mode 100644 tests/test_core/test_v1/__init__.py create mode 100644 tests/test_core/test_v1/test_event.py diff --git a/src/cloudevents/core/v1/__init__.py b/src/cloudevents/core/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py new file mode 100644 index 00000000..e4dc19ad --- /dev/null +++ b/src/cloudevents/core/v1/event.py @@ -0,0 +1,71 @@ +from typing import Optional +from datetime import datetime + +REQUIRED_ATTRIBUTES = {"id", "source", "type", "specversion"} +OPTIONAL_ATTRIBUTES = {"datacontenttype", "dataschema", "subject", "time"} + + +class CloudEvent: + def __init__(self, attributes: dict, data: Optional[dict] = None): + self.__validate_attribute(attributes) + self._attributes = attributes + self._data = data + + def __validate_attribute(self, attributes: dict): + missing_attributes = [ + attr for attr in REQUIRED_ATTRIBUTES if attr not in attributes + ] + if missing_attributes: + raise ValueError( + f"Missing required attribute(s): {', '.join(missing_attributes)}" + ) + + 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'") + + 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") + + if "subject" in attributes: + if not isinstance(attributes["subject"], str): + raise TypeError("Attribute 'subject' must be a string") + + if not attributes["subject"]: + raise ValueError("Attribute 'subject' must not be empty") + + if "datacontenttype" in attributes: + if not isinstance(attributes["datacontenttype"], str): + raise TypeError("Attribute 'datacontenttype' must be a string") + + if not attributes["datacontenttype"]: + raise ValueError("Attribute 'datacontenttype' must not be empty") + + if "dataschema" in attributes: + if not isinstance(attributes["dataschema"], str): + raise TypeError("Attribute 'dataschema' must be a string") + + if not attributes["dataschema"]: + raise ValueError("Attribute 'dataschema' must not be empty") + + def get_attribute(self, attribute: str): + return self._attributes[attribute] + + def get_data(self): + return self._data diff --git a/tests/test_core/__init__.py b/tests/test_core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_core/test_v1/__init__.py b/tests/test_core/test_v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py new file mode 100644 index 00000000..c4d46b6a --- /dev/null +++ b/tests/test_core/test_v1/test_event.py @@ -0,0 +1,158 @@ +from cloudevents.core.v1.event import CloudEvent + +import pytest +from datetime import datetime + + +@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, missing_attribute): + with pytest.raises(ValueError) as e: + CloudEvent(attributes) + + 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, error): + 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, error): + 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, error): + with pytest.raises((ValueError, TypeError)) as e: + CloudEvent( + {"id": "1", "source": "/", "type": "test", "specversion": specversion} + ) + + assert str(e.value) == error + + +@pytest.mark.parametrize( + "time,error", + [ + ("2023-10-25T17:09:19.736166Z", "Attribute 'time' must be a datetime object"), + ( + datetime(2023, 10, 25, 17, 9, 19, 736166), + "Attribute 'time' must be timezone aware", + ), + ], +) +def test_time_validation(time, error): + with pytest.raises((ValueError, TypeError)) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "time": time, + } + ) + + assert str(e.value) == error + + +@pytest.mark.parametrize( + "subject,error", + [ + (1234, "Attribute 'subject' must be a string"), + ( + "", + "Attribute 'subject' must not be empty", + ), + ], +) +def test_subject_validation(subject, error): + with pytest.raises((ValueError, TypeError)) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "subject": subject, + } + ) + + assert str(e.value) == error + + +@pytest.mark.parametrize( + "datacontenttype,error", + [ + (1234, "Attribute 'datacontenttype' must be a string"), + ( + "", + "Attribute 'datacontenttype' must not be empty", + ), + ], +) +def test_datacontenttype_validation(datacontenttype, error): + with pytest.raises((ValueError, TypeError)) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "datacontenttype": datacontenttype, + } + ) + + assert str(e.value) == error + + +@pytest.mark.parametrize( + "dataschema,error", + [ + (1234, "Attribute 'dataschema' must be a string"), + ( + "", + "Attribute 'dataschema' must not be empty", + ), + ], +) +def test_dataschema_validation(dataschema, error): + with pytest.raises((ValueError, TypeError)) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "dataschema": dataschema, + } + ) + + assert str(e.value) == error From 8db1e290b75181d2f82da5a5038721c0140bafa2 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Fri, 8 Nov 2024 17:02:43 +0200 Subject: [PATCH 02/20] chore: add typings and docstrings Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/__init__.py | 3 ++ src/cloudevents/core/v1/event.py | 48 ++++++++++++++++++++++++--- tests/test_core/test_v1/test_event.py | 16 ++++----- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/cloudevents/core/v1/__init__.py b/src/cloudevents/core/v1/__init__.py index e69de29b..a9fb8fb1 100644 --- a/src/cloudevents/core/v1/__init__.py +++ b/src/cloudevents/core/v1/__init__.py @@ -0,0 +1,3 @@ +""" +CloudEvent implementation for v1.0 +""" diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index e4dc19ad..9ae48958 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -1,4 +1,18 @@ -from typing import Optional +# 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. + +from typing import Any, Optional from datetime import datetime REQUIRED_ATTRIBUTES = {"id", "source", "type", "specversion"} @@ -6,7 +20,18 @@ class CloudEvent: - def __init__(self, attributes: dict, data: Optional[dict] = None): + def __init__(self, attributes: dict, data: Optional[dict] = None) -> 'CloudEvent': + """ + Create a new CloudEvent instance. + + :param attributes: The attributes of the CloudEvent instance. + :type attributes: dict + :param data: The payload of the CloudEvent instance. + :type data: Optional[dict] + + :raises ValueError: If any of the required attributes are missing or have invalid values. + :raises TypeError: If any of the attributes have invalid types. + """ self.__validate_attribute(attributes) self._attributes = attributes self._data = data @@ -64,8 +89,23 @@ def __validate_attribute(self, attributes: dict): if not attributes["dataschema"]: raise ValueError("Attribute 'dataschema' must not be empty") - def get_attribute(self, attribute: str): + def get_attribute(self, attribute: str) -> Optional[Any]: + """ + Retrieve a value of an attribute of the event denoted by the given `attribute`. + + :param attribute: The name of the event attribute to retrieve the value for. + :type attribute: str + + :return: The event attribute value. + :rtype: Optional[Any] + """ return self._attributes[attribute] - def get_data(self): + def get_data(self) -> Optional[dict]: + """ + Retrieve data of the event. + + :return: The data of the event. + :rtype: Optional[dict] + """ return self._data diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py index c4d46b6a..11dd0b94 100644 --- a/tests/test_core/test_v1/test_event.py +++ b/tests/test_core/test_v1/test_event.py @@ -13,7 +13,7 @@ ({"id": "1", "source": "/", "type": "test"}, "specversion"), ], ) -def test_missing_required_attribute(attributes, missing_attribute): +def test_missing_required_attribute(attributes, missing_attribute) -> None: with pytest.raises(ValueError) as e: CloudEvent(attributes) @@ -27,7 +27,7 @@ def test_missing_required_attribute(attributes, missing_attribute): (12, "Attribute 'id' must be a string"), ], ) -def test_id_validation(id, error): +def test_id_validation(id, error) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent({"id": id, "source": "/", "type": "test", "specversion": "1.0"}) @@ -35,7 +35,7 @@ def test_id_validation(id, error): @pytest.mark.parametrize("source,error", [(123, "Attribute 'source' must be a string")]) -def test_source_validation(source, error): +def test_source_validation(source, error) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent({"id": "1", "source": source, "type": "test", "specversion": "1.0"}) @@ -49,7 +49,7 @@ def test_source_validation(source, error): ("1.4", "Attribute 'specversion' must be '1.0'"), ], ) -def test_specversion_validation(specversion, error): +def test_specversion_validation(specversion, error) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent( {"id": "1", "source": "/", "type": "test", "specversion": specversion} @@ -68,7 +68,7 @@ def test_specversion_validation(specversion, error): ), ], ) -def test_time_validation(time, error): +def test_time_validation(time, error) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent( { @@ -93,7 +93,7 @@ def test_time_validation(time, error): ), ], ) -def test_subject_validation(subject, error): +def test_subject_validation(subject, error) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent( { @@ -118,7 +118,7 @@ def test_subject_validation(subject, error): ), ], ) -def test_datacontenttype_validation(datacontenttype, error): +def test_datacontenttype_validation(datacontenttype, error) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent( { @@ -143,7 +143,7 @@ def test_datacontenttype_validation(datacontenttype, error): ), ], ) -def test_dataschema_validation(dataschema, error): +def test_dataschema_validation(dataschema, error) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent( { From 35dee7d2c1a620529f4f4c2969c83f2b73ce5ef5 Mon Sep 17 00:00:00 2001 From: Tudor Date: Sat, 9 Nov 2024 19:43:24 +0200 Subject: [PATCH 03/20] chore: Add support for custom extension names and validate them Signed-off-by: Tudor --- src/cloudevents/core/v1/event.py | 39 ++++++++++++++++--- tests/test_core/test_v1/test_event.py | 54 +++++++++++++++++++++++---- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index 9ae48958..eea60108 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -14,13 +14,14 @@ from typing import Any, Optional from datetime import datetime +import re REQUIRED_ATTRIBUTES = {"id", "source", "type", "specversion"} OPTIONAL_ATTRIBUTES = {"datacontenttype", "dataschema", "subject", "time"} class CloudEvent: - def __init__(self, attributes: dict, data: Optional[dict] = None) -> 'CloudEvent': + def __init__(self, attributes: dict, data: Optional[dict] = None) -> None: """ Create a new CloudEvent instance. @@ -32,11 +33,14 @@ def __init__(self, attributes: dict, data: Optional[dict] = None) -> 'CloudEvent :raises ValueError: If any of the required attributes are missing or have invalid values. :raises TypeError: If any of the attributes have invalid types. """ - self.__validate_attribute(attributes) + self._validate_attribute(attributes) self._attributes = attributes self._data = data - def __validate_attribute(self, attributes: dict): + def _validate_attribute(self, attributes: dict) -> None: + """ + Private method that validates the attributes of the CloudEvent as per the CloudEvents specification. + """ missing_attributes = [ attr for attr in REQUIRED_ATTRIBUTES if attr not in attributes ] @@ -47,6 +51,7 @@ def __validate_attribute(self, attributes: dict): 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") @@ -58,6 +63,7 @@ def __validate_attribute(self, attributes: dict): 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'") @@ -89,13 +95,36 @@ def __validate_attribute(self, attributes: dict): if not attributes["dataschema"]: raise ValueError("Attribute 'dataschema' must not be empty") + for custom_extension in ( + set(attributes.keys()) - REQUIRED_ATTRIBUTES - OPTIONAL_ATTRIBUTES + ): + if custom_extension == "data": + raise ValueError( + "Extension attribute 'data' is reserved and must not be used" + ) + + if not custom_extension[0].isalpha(): + raise ValueError( + f"Extension attribute '{custom_extension}' should start with a letter" + ) + + if not (5 <= len(custom_extension) <= 20): + raise ValueError( + f"Extension attribute '{custom_extension}' should be between 5 and 20 characters long" + ) + + if not re.match(r"^[a-z0-9]+$", custom_extension): + raise ValueError( + f"Extension attribute '{custom_extension}' should only contain lowercase letters and numbers" + ) + def get_attribute(self, attribute: str) -> Optional[Any]: """ Retrieve a value of an attribute of the event denoted by the given `attribute`. - + :param attribute: The name of the event attribute to retrieve the value for. :type attribute: str - + :return: The event attribute value. :rtype: Optional[Any] """ diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py index 11dd0b94..fcf541e2 100644 --- a/tests/test_core/test_v1/test_event.py +++ b/tests/test_core/test_v1/test_event.py @@ -2,6 +2,7 @@ import pytest from datetime import datetime +from typing import Any, Optional @pytest.mark.parametrize( @@ -13,7 +14,7 @@ ({"id": "1", "source": "/", "type": "test"}, "specversion"), ], ) -def test_missing_required_attribute(attributes, missing_attribute) -> None: +def test_missing_required_attribute(attributes: dict, missing_attribute: str) -> None: with pytest.raises(ValueError) as e: CloudEvent(attributes) @@ -27,7 +28,7 @@ def test_missing_required_attribute(attributes, missing_attribute) -> None: (12, "Attribute 'id' must be a string"), ], ) -def test_id_validation(id, error) -> None: +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"}) @@ -35,7 +36,7 @@ def test_id_validation(id, error) -> None: @pytest.mark.parametrize("source,error", [(123, "Attribute 'source' must be a string")]) -def test_source_validation(source, error) -> None: +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"}) @@ -49,7 +50,7 @@ def test_source_validation(source, error) -> None: ("1.4", "Attribute 'specversion' must be '1.0'"), ], ) -def test_specversion_validation(specversion, error) -> None: +def test_specversion_validation(specversion: Any, error: str) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent( {"id": "1", "source": "/", "type": "test", "specversion": specversion} @@ -68,7 +69,7 @@ def test_specversion_validation(specversion, error) -> None: ), ], ) -def test_time_validation(time, error) -> None: +def test_time_validation(time: Any, error: str) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent( { @@ -93,7 +94,7 @@ def test_time_validation(time, error) -> None: ), ], ) -def test_subject_validation(subject, error) -> None: +def test_subject_validation(subject: Any, error: str) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent( { @@ -118,7 +119,7 @@ def test_subject_validation(subject, error) -> None: ), ], ) -def test_datacontenttype_validation(datacontenttype, error) -> None: +def test_datacontenttype_validation(datacontenttype: Any, error: str) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent( { @@ -143,7 +144,7 @@ def test_datacontenttype_validation(datacontenttype, error) -> None: ), ], ) -def test_dataschema_validation(dataschema, error) -> None: +def test_dataschema_validation(dataschema: Any, error: str) -> None: with pytest.raises((ValueError, TypeError)) as e: CloudEvent( { @@ -156,3 +157,40 @@ def test_dataschema_validation(dataschema, error) -> None: ) assert str(e.value) == error + + +@pytest.mark.parametrize( + "extension_name,error", + [ + ("123", "Extension attribute '123' should start with a letter"), + ( + "shrt", + "Extension attribute 'shrt' should be between 5 and 20 characters long", + ), + ( + "thisisaverylongextension", + "Extension attribute 'thisisaverylongextension' should be between 5 and 20 characters long", + ), + ( + "ThisIsNotValid", + "Extension attribute 'ThisIsNotValid' should only contain lowercase letters and numbers", + ), + ( + "data", + "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: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + extension_name: "value", + } + ) + + assert str(e.value) == error From 42b4fe1d3a57664d00770011c290ba0fe7dc9c1c Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Sat, 9 Nov 2024 21:48:03 +0200 Subject: [PATCH 04/20] chore: Add copyright and fix missing type info Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/__init__.py | 14 +++++++ src/cloudevents/core/v1/event.py | 54 +++++++++++++++------------ tests/test_core/__init__.py | 13 +++++++ tests/test_core/test_v1/__init__.py | 13 +++++++ tests/test_core/test_v1/test_event.py | 21 +++++++++-- 5 files changed, 87 insertions(+), 28 deletions(-) diff --git a/src/cloudevents/core/v1/__init__.py b/src/cloudevents/core/v1/__init__.py index a9fb8fb1..896dfe12 100644 --- a/src/cloudevents/core/v1/__init__.py +++ b/src/cloudevents/core/v1/__init__.py @@ -1,3 +1,17 @@ +# 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. + """ CloudEvent implementation for v1.0 """ diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index eea60108..ec9abe08 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -12,34 +12,48 @@ # License for the specific language governing permissions and limitations # under the License. -from typing import Any, Optional +from typing import Any, Optional, Final from datetime import datetime import re -REQUIRED_ATTRIBUTES = {"id", "source", "type", "specversion"} -OPTIONAL_ATTRIBUTES = {"datacontenttype", "dataschema", "subject", "time"} +REQUIRED_ATTRIBUTES: Final[set[str]] = {"id", "source", "type", "specversion"} +OPTIONAL_ATTRIBUTES: Final[set[str]] = { + "datacontenttype", + "dataschema", + "subject", + "time", +} class CloudEvent: - def __init__(self, attributes: dict, data: Optional[dict] = None) -> None: + """ + The CloudEvent Python wrapper contract exposing generically-available + properties and APIs. + + Implementations might handle fields and have other APIs exposed but are + obliged to follow this contract. + """ + + def __init__(self, attributes: dict[str, Any], data: Optional[dict] = None) -> None: """ Create a new CloudEvent instance. :param attributes: The attributes of the CloudEvent instance. - :type attributes: dict :param data: The payload of the CloudEvent instance. - :type data: Optional[dict] :raises ValueError: If any of the required attributes are missing or have invalid values. :raises TypeError: If any of the attributes have invalid types. """ self._validate_attribute(attributes) - self._attributes = attributes - self._data = data + self._attributes: dict = attributes + self._data: Optional[dict] = data - def _validate_attribute(self, attributes: dict) -> None: + @staticmethod + def _validate_attribute(attributes: dict) -> None: """ - Private method that validates the attributes of the CloudEvent as per the CloudEvents specification. + Validates the attributes of the CloudEvent as per the CloudEvents specification. + + See https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#required-attributes """ missing_attributes = [ attr for attr in REQUIRED_ATTRIBUTES if attr not in attributes @@ -95,27 +109,22 @@ def _validate_attribute(self, attributes: dict) -> None: if not attributes["dataschema"]: raise ValueError("Attribute 'dataschema' must not be empty") - for custom_extension in ( + for extension_attributes in ( set(attributes.keys()) - REQUIRED_ATTRIBUTES - OPTIONAL_ATTRIBUTES ): - if custom_extension == "data": + if extension_attributes == "data": raise ValueError( "Extension attribute 'data' is reserved and must not be used" ) - if not custom_extension[0].isalpha(): - raise ValueError( - f"Extension attribute '{custom_extension}' should start with a letter" - ) - - if not (5 <= len(custom_extension) <= 20): + if not (1 <= len(extension_attributes) <= 20): raise ValueError( - f"Extension attribute '{custom_extension}' should be between 5 and 20 characters long" + f"Extension attribute '{extension_attributes}' should be between 1 and 20 characters long" ) - if not re.match(r"^[a-z0-9]+$", custom_extension): + if not re.match(r"^[a-z0-9]+$", extension_attributes): raise ValueError( - f"Extension attribute '{custom_extension}' should only contain lowercase letters and numbers" + f"Extension attribute '{extension_attributes}' should only contain lowercase letters and numbers" ) def get_attribute(self, attribute: str) -> Optional[Any]: @@ -123,10 +132,8 @@ def get_attribute(self, attribute: str) -> Optional[Any]: Retrieve a value of an attribute of the event denoted by the given `attribute`. :param attribute: The name of the event attribute to retrieve the value for. - :type attribute: str :return: The event attribute value. - :rtype: Optional[Any] """ return self._attributes[attribute] @@ -135,6 +142,5 @@ def get_data(self) -> Optional[dict]: Retrieve data of the event. :return: The data of the event. - :rtype: Optional[dict] """ return self._data diff --git a/tests/test_core/__init__.py b/tests/test_core/__init__.py index e69de29b..8043675e 100644 --- a/tests/test_core/__init__.py +++ b/tests/test_core/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/tests/test_core/test_v1/__init__.py b/tests/test_core/test_v1/__init__.py index e69de29b..8043675e 100644 --- a/tests/test_core/test_v1/__init__.py +++ b/tests/test_core/test_v1/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py index fcf541e2..27329783 100644 --- a/tests/test_core/test_v1/test_event.py +++ b/tests/test_core/test_v1/test_event.py @@ -1,3 +1,17 @@ +# 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. + from cloudevents.core.v1.event import CloudEvent import pytest @@ -162,14 +176,13 @@ def test_dataschema_validation(dataschema: Any, error: str) -> None: @pytest.mark.parametrize( "extension_name,error", [ - ("123", "Extension attribute '123' should start with a letter"), ( - "shrt", - "Extension attribute 'shrt' should be between 5 and 20 characters long", + "", + "Extension attribute '' should be between 1 and 20 characters long", ), ( "thisisaverylongextension", - "Extension attribute 'thisisaverylongextension' should be between 5 and 20 characters long", + "Extension attribute 'thisisaverylongextension' should be between 1 and 20 characters long", ), ( "ThisIsNotValid", From f83c363cf5bbfb6a2cb031e48c00b7f5e45f00f8 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Sat, 9 Nov 2024 22:02:23 +0200 Subject: [PATCH 05/20] chore: Add getters for attributes and test happy path Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 64 ++++++++++++++++++++++++--- tests/test_core/test_v1/test_event.py | 38 +++++++++++++++- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index ec9abe08..8b84746f 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -127,15 +127,69 @@ def _validate_attribute(attributes: dict) -> None: f"Extension attribute '{extension_attributes}' should only contain lowercase letters and numbers" ) - def get_attribute(self, attribute: str) -> Optional[Any]: + def get_id(self) -> str: """ - Retrieve a value of an attribute of the event denoted by the given `attribute`. + Retrieve the ID of the event. - :param attribute: The name of the event attribute to retrieve the value for. + :return: The ID of the event. + """ + return self._attributes["id"] + + def get_source(self) -> str: + """ + Retrieve the source of the event. + + :return: The source of the event. + """ + return self._attributes["source"] + + def get_type(self) -> str: + """ + Retrieve the type of the event. + + :return: The type of the event. + """ + return self._attributes["type"] + + def get_specversion(self) -> str: + """ + Retrieve the specversion of the event. + + :return: The specversion of the event. + """ + return self._attributes["specversion"] + + def get_datacontenttype(self) -> Optional[str]: + """ + Retrieve the datacontenttype of the event. + + :return: The datacontenttype of the event. + """ + return self._attributes.get("datacontenttype") + + def get_dataschema(self) -> Optional[str]: + """ + Retrieve the dataschema of the event. + + :return: The dataschema of the event. + """ + return self._attributes.get("dataschema") + + def get_subject(self) -> Optional[str]: + """ + Retrieve the subject of the event. + + :return: The subject of the event. + """ + return self._attributes.get("subject") + + def get_time(self) -> Optional[datetime]: + """ + Retrieve the time of the event. - :return: The event attribute value. + :return: The time of the event. """ - return self._attributes[attribute] + return self._attributes.get("time") def get_data(self) -> Optional[dict]: """ diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py index 27329783..25b57542 100644 --- a/tests/test_core/test_v1/test_event.py +++ b/tests/test_core/test_v1/test_event.py @@ -15,7 +15,7 @@ from cloudevents.core.v1.event import CloudEvent import pytest -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Optional @@ -207,3 +207,39 @@ def test_custom_extension(extension_name: str, error: str) -> None: ) assert str(e.value) == error + + +def test_cloud_event_constructor() -> None: + id = "1" + source = "/source" + type = "com.test.type" + specversion = "1.0" + datacontenttype = "application/json" + dataschema = "http://example.com/schema" + subject = "test_subject" + time = datetime.now(tz=timezone.utc) + data = {"key": "value"} + + event = CloudEvent( + attributes={ + "id": id, + "source": source, + "type": type, + "specversion": specversion, + "datacontenttype": datacontenttype, + "dataschema": dataschema, + "subject": subject, + "time": time, + }, + data=data, + ) + + assert event.get_id() == id + assert event.get_source() == source + assert event.get_type() == type + assert event.get_specversion() == specversion + assert event.get_datacontenttype() == datacontenttype + assert event.get_dataschema() == dataschema + assert event.get_subject() == subject + assert event.get_time() == time + assert event.get_data() == data From 9d1aa358a897f21550bb279133c075ffef4ccd88 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Sat, 9 Nov 2024 22:20:27 +0200 Subject: [PATCH 06/20] fix: typing Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index 8b84746f..c0a3c17f 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -45,11 +45,11 @@ def __init__(self, attributes: dict[str, Any], data: Optional[dict] = None) -> N :raises TypeError: If any of the attributes have invalid types. """ self._validate_attribute(attributes) - self._attributes: dict = attributes + self._attributes: dict[str, Any] = attributes self._data: Optional[dict] = data @staticmethod - def _validate_attribute(attributes: dict) -> None: + def _validate_attribute(attributes: dict[str, Any]) -> None: """ Validates the attributes of the CloudEvent as per the CloudEvents specification. From aa81ca0d58c2e3c2152411e598201405332ff73d Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Mon, 11 Nov 2024 16:21:26 +0200 Subject: [PATCH 07/20] chore: Split validation logic into smaller methods Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 53 +++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index c0a3c17f..11446774 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -55,6 +55,19 @@ def _validate_attribute(attributes: dict[str, Any]) -> None: 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) + + @staticmethod + def _validate_required_attributes(attributes: dict[str, Any]) -> None: + """ + 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. + """ missing_attributes = [ attr for attr in REQUIRED_ATTRIBUTES if attr not in attributes ] @@ -63,52 +76,66 @@ def _validate_attribute(attributes: dict[str, Any]) -> None: f"Missing required attribute(s): {', '.join(missing_attributes)}" ) + @staticmethod + def _validate_attribute_types(attributes: dict[str, Any]) -> None: + """ + 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. + """ 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'") + @staticmethod + def _validate_optional_attributes(attributes: dict[str, Any]) -> None: + """ + 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. + """ 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") - if "subject" in attributes: if not isinstance(attributes["subject"], str): raise TypeError("Attribute 'subject' must be a string") - if not attributes["subject"]: raise ValueError("Attribute 'subject' must not be empty") - if "datacontenttype" in attributes: if not isinstance(attributes["datacontenttype"], str): raise TypeError("Attribute 'datacontenttype' must be a string") - if not attributes["datacontenttype"]: raise ValueError("Attribute 'datacontenttype' must not be empty") - if "dataschema" in attributes: if not isinstance(attributes["dataschema"], str): raise TypeError("Attribute 'dataschema' must be a string") - if not attributes["dataschema"]: raise ValueError("Attribute 'dataschema' must not be empty") + @staticmethod + def _validate_extension_attributes(attributes: dict[str, Any]) -> None: + """ + Validates the extension attributes. + + :param attributes: The attributes of the CloudEvent instance. + :raises ValueError: If any of the extension attributes have invalid values. + """ for extension_attributes in ( set(attributes.keys()) - REQUIRED_ATTRIBUTES - OPTIONAL_ATTRIBUTES ): @@ -116,12 +143,10 @@ def _validate_attribute(attributes: dict[str, Any]) -> None: raise ValueError( "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 re.match(r"^[a-z0-9]+$", extension_attributes): raise ValueError( f"Extension attribute '{extension_attributes}' should only contain lowercase letters and numbers" From b2b06495430f72e8bb274acb59528fdf80c88319 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Mon, 11 Nov 2024 16:28:43 +0200 Subject: [PATCH 08/20] chore: Add method to extract extension by name Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 9 +++++++++ tests/test_core/test_v1/test_event.py | 3 +++ 2 files changed, 12 insertions(+) diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index 11446774..2d821681 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -215,6 +215,15 @@ def get_time(self) -> Optional[datetime]: :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. + + :param extension_name: The name of the extension attribute. + :return: The value of the extension attribute. + """ + return self._attributes.get(extension_name) def get_data(self) -> Optional[dict]: """ diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py index 25b57542..bc200792 100644 --- a/tests/test_core/test_v1/test_event.py +++ b/tests/test_core/test_v1/test_event.py @@ -219,6 +219,7 @@ def test_cloud_event_constructor() -> None: subject = "test_subject" time = datetime.now(tz=timezone.utc) data = {"key": "value"} + customextension = "customExtension" event = CloudEvent( attributes={ @@ -230,6 +231,7 @@ def test_cloud_event_constructor() -> None: "dataschema": dataschema, "subject": subject, "time": time, + "customextension": customextension, }, data=data, ) @@ -242,4 +244,5 @@ def test_cloud_event_constructor() -> None: assert event.get_dataschema() == dataschema assert event.get_subject() == subject assert event.get_time() == time + assert event.get_extension("customextension") == customextension assert event.get_data() == data From b2023250a7fd724ef5a3566cdfb83993d881d73e Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Mon, 11 Nov 2024 20:34:15 +0200 Subject: [PATCH 09/20] chore: configure ruff to sort imports also Signed-off-by: Tudor Plugaru --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 52fb4bb3..e587d7ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,8 @@ exclude = [ [tool.ruff.lint] ignore = ["E731"] extend-ignore = ["E203"] +select = ["I"] + [tool.pytest.ini_options] testpaths = [ From c5e6df9ec86a313d85c06e3a9e973f3fd9380602 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Mon, 11 Nov 2024 20:35:54 +0200 Subject: [PATCH 10/20] chore: Returns all the errors at ones instead of raising early. Improve tests Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 141 +++++++++++++++---------- src/cloudevents/core/v1/exceptions.py | 27 +++++ tests/test_core/test_v1/test_event.py | 142 +++++++++++--------------- 3 files changed, 172 insertions(+), 138 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 2d821681..be5a2629 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 + +from cloudevents.core.v1.exceptions import CloudEventValidationError -REQUIRED_ATTRIBUTES: Final[set[str]] = {"id", "source", "type", "specversion"} -OPTIONAL_ATTRIBUTES: Final[set[str]] = { +REQUIRED_ATTRIBUTES: Final[list[str]] = ["id", "source", "type", "specversion"] +OPTIONAL_ATTRIBUTES: Final[list[str]] = [ "datacontenttype", "dataschema", "subject", "time", -} +] class CloudEvent: @@ -55,102 +57,129 @@ def _validate_attribute(attributes: dict[str, Any]) -> None: 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. - """ - 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'") + :return: A dictionary of validation error messages. + """ + 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. - """ - for extension_attributes in ( - set(attributes.keys()) - REQUIRED_ATTRIBUTES - OPTIONAL_ATTRIBUTES - ): - if extension_attributes == "data": - raise ValueError( + :return: A dictionary of validation error messages. + """ + 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 @@ def get_time(self) -> Optional[datetime]: :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 00000000..cae226a7 --- /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 bc200792..a6ca1cde 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 - -import pytest from datetime import datetime, timezone -from typing import Any, Optional - - -@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) - - 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 +from typing import Any +import pytest -@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 +from cloudevents.core.v1.event import CloudEvent +from cloudevents.core.v1.exceptions import CloudEventValidationError -@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} - ) +def test_missing_required_attributes() -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent({}) - 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: From 6e13f723aa3a0b4981a7a9f5d0427a01bbd83181 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Mon, 11 Nov 2024 21:55:20 +0200 Subject: [PATCH 11/20] fix missing type info Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cloudevents/core/v1/exceptions.py b/src/cloudevents/core/v1/exceptions.py index cae226a7..44397221 100644 --- a/src/cloudevents/core/v1/exceptions.py +++ b/src/cloudevents/core/v1/exceptions.py @@ -18,7 +18,7 @@ class CloudEventValidationError(Exception): def __init__(self, errors: dict[str, list[str]]) -> None: super().__init__("Validation errors occurred") - self.errors = errors + self.errors: dict[str, list[str]] = errors def __str__(self) -> str: error_messages = [ From e78a70b69d983fc992c77e32e1d7b73e2498a775 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Wed, 13 Nov 2024 17:56:07 +0200 Subject: [PATCH 12/20] chore: Improve exceptions handling. Have exceptions grouped by attribute name and typed exceptions Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 150 +++++++++++------- src/cloudevents/core/v1/exceptions.py | 39 ++++- tests/test_core/test_v1/test_event.py | 215 +++++++++++++++++++++----- 3 files changed, 301 insertions(+), 103 deletions(-) diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index be5a2629..20b73499 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -13,10 +13,17 @@ # under the License. import re +from collections import defaultdict from datetime import datetime from typing import Any, Final, Optional -from cloudevents.core.v1.exceptions import CloudEventValidationError +from cloudevents.core.v1.exceptions import ( + BaseCloudEventException, + CloudEventValidationError, + CustomExtensionAttributeError, + InvalidAttributeTypeError, + MissingRequiredAttributeError, +) REQUIRED_ATTRIBUTES: Final[list[str]] = ["id", "source", "type", "specversion"] OPTIONAL_ATTRIBUTES: Final[list[str]] = [ @@ -57,108 +64,133 @@ def _validate_attribute(attributes: dict[str, Any]) -> None: See https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#required-attributes """ - errors = {} + errors: dict[str, list] = defaultdict(list) 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) + raise CloudEventValidationError(dict(errors)) @staticmethod 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. - :return: A dictionary of validation error messages. - """ - errors = {} - missing_attributes = [ - attr for attr in REQUIRED_ATTRIBUTES if attr not in attributes - ] - if missing_attributes: - errors["required"] = [ - f"Missing required attribute(s): {', '.join(missing_attributes)}" - ] - return errors - - @staticmethod - def _validate_attribute_types(attributes: dict[str, Any]) -> dict[str, list[str]]: + ) -> dict[str, list[BaseCloudEventException]]: """ Validates the types of the required attributes. :param attributes: The attributes of the CloudEvent instance. :return: A dictionary of validation error messages. """ - errors = {} - type_errors = [] + errors = defaultdict(list) + + if "id" not in attributes: + errors["id"].append(MissingRequiredAttributeError(missing="id")) if attributes.get("id") is None: - type_errors.append("Attribute 'id' must not be None") + errors["id"].append( + InvalidAttributeTypeError("Attribute 'id' must not be None") + ) if not isinstance(attributes.get("id"), str): - type_errors.append("Attribute 'id' must be a string") + errors["id"].append( + InvalidAttributeTypeError("Attribute 'id' must be a string") + ) + + if "source" not in attributes: + errors["source"].append(MissingRequiredAttributeError(missing="source")) if not isinstance(attributes.get("source"), str): - type_errors.append("Attribute 'source' must be a string") + errors["source"].append( + InvalidAttributeTypeError("Attribute 'source' must be a string") + ) + + if "type" not in attributes: + errors["type"].append(MissingRequiredAttributeError(missing="type")) if not isinstance(attributes.get("type"), str): - type_errors.append("Attribute 'type' must be a string") + errors["type"].append( + InvalidAttributeTypeError("Attribute 'type' must be a string") + ) + + if "specversion" not in attributes: + errors["specversion"].append( + MissingRequiredAttributeError(missing="specversion") + ) if not isinstance(attributes.get("specversion"), str): - type_errors.append("Attribute 'specversion' must be a string") + errors["specversion"].append( + InvalidAttributeTypeError("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 + errors["specversion"].append( + InvalidAttributeTypeError("Attribute 'specversion' must be '1.0'") + ) return errors @staticmethod def _validate_optional_attributes( attributes: dict[str, Any], - ) -> dict[str, list[str]]: + ) -> dict[str, list[BaseCloudEventException]]: """ Validates the types and values of the optional attributes. :param attributes: The attributes of the CloudEvent instance. :return: A dictionary of validation error messages. """ - errors = {} - optional_errors = [] + errors = defaultdict(list) + if "time" in attributes: if not isinstance(attributes["time"], datetime): - optional_errors.append("Attribute 'time' must be a datetime object") + errors["time"].append( + InvalidAttributeTypeError( + "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") + errors["time"].append( + InvalidAttributeTypeError("Attribute 'time' must be timezone aware") + ) if "subject" in attributes: if not isinstance(attributes["subject"], str): - optional_errors.append("Attribute 'subject' must be a string") + errors["subject"].append( + InvalidAttributeTypeError("Attribute 'subject' must be a string") + ) if not attributes["subject"]: - optional_errors.append("Attribute 'subject' must not be empty") + errors["subject"].append( + InvalidAttributeTypeError("Attribute 'subject' must not be empty") + ) if "datacontenttype" in attributes: if not isinstance(attributes["datacontenttype"], str): - optional_errors.append("Attribute 'datacontenttype' must be a string") + errors["datacontenttype"].append( + InvalidAttributeTypeError( + "Attribute 'datacontenttype' must be a string" + ) + ) if not attributes["datacontenttype"]: - optional_errors.append("Attribute 'datacontenttype' must not be empty") + errors["datacontenttype"].append( + InvalidAttributeTypeError( + "Attribute 'datacontenttype' must not be empty" + ) + ) if "dataschema" in attributes: if not isinstance(attributes["dataschema"], str): - optional_errors.append("Attribute 'dataschema' must be a string") + errors["dataschema"].append( + InvalidAttributeTypeError("Attribute 'dataschema' must be a string") + ) if not attributes["dataschema"]: - optional_errors.append("Attribute 'dataschema' must not be empty") - if optional_errors: - errors["optional"] = optional_errors + errors["dataschema"].append( + InvalidAttributeTypeError( + "Attribute 'dataschema' must not be empty" + ) + ) return errors @staticmethod def _validate_extension_attributes( attributes: dict[str, Any], - ) -> dict[str, list[str]]: + ) -> dict[str, list[BaseCloudEventException]]: """ Validates the extension attributes. :param attributes: The attributes of the CloudEvent instance. :return: A dictionary of validation error messages. """ - errors = {} - extension_errors = [] + errors = defaultdict(list) extension_attributes = [ key for key in attributes.keys() @@ -166,19 +198,23 @@ def _validate_extension_attributes( ] for extension_attribute in extension_attributes: if extension_attribute == "data": - extension_errors.append( - "Extension attribute 'data' is reserved and must not be used" + errors[extension_attribute].append( + CustomExtensionAttributeError( + "Extension attribute 'data' is reserved and must not be used" + ) ) if not (1 <= len(extension_attribute) <= 20): - extension_errors.append( - f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long" + errors[extension_attribute].append( + CustomExtensionAttributeError( + f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long" + ) ) 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" + errors[extension_attribute].append( + CustomExtensionAttributeError( + 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: diff --git a/src/cloudevents/core/v1/exceptions.py b/src/cloudevents/core/v1/exceptions.py index 44397221..0d8680db 100644 --- a/src/cloudevents/core/v1/exceptions.py +++ b/src/cloudevents/core/v1/exceptions.py @@ -11,17 +11,46 @@ # 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): +class BaseCloudEventException(Exception): + pass + + +class CloudEventValidationError(BaseCloudEventException): """ - Custom exception for validation errors. + Holds validation errors aggregated during a CloudEvent creation. """ - def __init__(self, errors: dict[str, list[str]]) -> None: + def __init__(self, errors: dict[str, list[BaseCloudEventException]]) -> None: + """ + :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") - self.errors: dict[str, list[str]] = errors + self.errors: dict[str, list[BaseCloudEventException]] = errors def __str__(self) -> str: error_messages = [ - f"{key}: {', '.join(value)}" for key, value in self.errors.items() + f"{key}: {', '.join(str(value))}" for key, value in self.errors.items() ] return f"{super().__str__()}: {', '.join(error_messages)}" + + +class MissingRequiredAttributeError(BaseCloudEventException): + """ + Exception for missing required attribute. + """ + + def __init__(self, missing: str) -> None: + super().__init__(f"Missing required attribute: '{missing}'") + + +class CustomExtensionAttributeError(BaseCloudEventException): + """ + Exception for invalid custom extension names. + """ + + +class InvalidAttributeTypeError(BaseCloudEventException): + """ + Exception for invalid attribute type. + """ diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py index a6ca1cde..1ee9886a 100644 --- a/tests/test_core/test_v1/test_event.py +++ b/tests/test_core/test_v1/test_event.py @@ -18,41 +18,87 @@ import pytest from cloudevents.core.v1.event import CloudEvent -from cloudevents.core.v1.exceptions import CloudEventValidationError +from cloudevents.core.v1.exceptions import ( + CloudEventValidationError, + CustomExtensionAttributeError, + InvalidAttributeTypeError, + MissingRequiredAttributeError, +) def test_missing_required_attributes() -> None: with pytest.raises(CloudEventValidationError) as e: CloudEvent({}) - assert e.value.errors == { - "required": ["Missing required attribute(s): id, source, type, specversion"], + expected_errors = { + "id": [ + str(MissingRequiredAttributeError("id")), + str(InvalidAttributeTypeError("Attribute 'id' must not be None")), + str(InvalidAttributeTypeError("Attribute 'id' must be a string")), + ], + "source": [ + str(MissingRequiredAttributeError("source")), + str(InvalidAttributeTypeError("Attribute 'source' must be a string")), + ], "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'", + str(MissingRequiredAttributeError("type")), + str(InvalidAttributeTypeError("Attribute 'type' must be a string")), ], + "specversion": [ + str(MissingRequiredAttributeError("specversion")), + str(InvalidAttributeTypeError("Attribute 'specversion' must be a string")), + str(InvalidAttributeTypeError("Attribute 'specversion' must be '1.0'")), + ], + } + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() } + assert actual_errors == expected_errors @pytest.mark.parametrize( - "time,error", + "time,expected_error", [ ( "2023-10-25T17:09:19.736166Z", - {"optional": ["Attribute 'time' must be a datetime object"]}, + { + "time": [ + str( + InvalidAttributeTypeError( + "Attribute 'time' must be a datetime object" + ) + ) + ] + }, ), ( datetime(2023, 10, 25, 17, 9, 19, 736166), - {"optional": ["Attribute 'time' must be timezone aware"]}, + { + "time": [ + str( + InvalidAttributeTypeError( + "Attribute 'time' must be timezone aware" + ) + ) + ] + }, + ), + ( + 1, + { + "time": [ + str( + InvalidAttributeTypeError( + "Attribute 'time' must be a datetime object" + ) + ) + ] + }, ), - (1, {"optional": ["Attribute 'time' must be a datetime object"]}), ], ) -def test_time_validation(time: Any, error: dict) -> None: +def test_time_validation(time: Any, expected_error: dict) -> None: with pytest.raises(CloudEventValidationError) as e: CloudEvent( { @@ -63,21 +109,42 @@ def test_time_validation(time: Any, error: dict) -> None: "time": time, } ) - - assert e.value.errors == error + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error @pytest.mark.parametrize( - "subject,error", + "subject,expected_error", [ - (1234, {"optional": ["Attribute 'subject' must be a string"]}), + ( + 1234, + { + "subject": [ + str( + InvalidAttributeTypeError( + "Attribute 'subject' must be a string" + ) + ) + ] + }, + ), ( "", - {"optional": ["Attribute 'subject' must not be empty"]}, + { + "subject": [ + str( + InvalidAttributeTypeError( + "Attribute 'subject' must not be empty" + ) + ) + ] + }, ), ], ) -def test_subject_validation(subject: Any, error: dict) -> None: +def test_subject_validation(subject: Any, expected_error: dict) -> None: with pytest.raises(CloudEventValidationError) as e: CloudEvent( { @@ -89,20 +156,42 @@ def test_subject_validation(subject: Any, error: dict) -> None: } ) - assert e.value.errors == error + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error @pytest.mark.parametrize( - "datacontenttype,error", + "datacontenttype,expected_error", [ - (1234, {"optional": ["Attribute 'datacontenttype' must be a string"]}), + ( + 1234, + { + "datacontenttype": [ + str( + InvalidAttributeTypeError( + "Attribute 'datacontenttype' must be a string" + ) + ) + ] + }, + ), ( "", - {"optional": ["Attribute 'datacontenttype' must not be empty"]}, + { + "datacontenttype": [ + str( + InvalidAttributeTypeError( + "Attribute 'datacontenttype' must not be empty" + ) + ) + ] + }, ), ], ) -def test_datacontenttype_validation(datacontenttype: Any, error: dict) -> None: +def test_datacontenttype_validation(datacontenttype: Any, expected_error: dict) -> None: with pytest.raises(CloudEventValidationError) as e: CloudEvent( { @@ -114,20 +203,42 @@ def test_datacontenttype_validation(datacontenttype: Any, error: dict) -> None: } ) - assert e.value.errors == error + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error @pytest.mark.parametrize( - "dataschema,error", + "dataschema,expected_error", [ - (1234, {"optional": ["Attribute 'dataschema' must be a string"]}), + ( + 1234, + { + "dataschema": [ + str( + InvalidAttributeTypeError( + "Attribute 'dataschema' must be a string" + ) + ) + ] + }, + ), ( "", - {"optional": ["Attribute 'dataschema' must not be empty"]}, + { + "dataschema": [ + str( + InvalidAttributeTypeError( + "Attribute 'dataschema' must not be empty" + ) + ) + ] + }, ), ], ) -def test_dataschema_validation(dataschema: Any, error: str) -> None: +def test_dataschema_validation(dataschema: Any, expected_error: dict) -> None: with pytest.raises(CloudEventValidationError) as e: CloudEvent( { @@ -139,40 +250,59 @@ def test_dataschema_validation(dataschema: Any, error: str) -> None: } ) - assert e.value.errors == error + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error @pytest.mark.parametrize( - "extension_name,error", + "extension_name,expected_error", [ ( "", { - "extensions": [ - "Extension attribute '' should be between 1 and 20 characters long", - "Extension attribute '' should only contain lowercase letters and numbers", + "": [ + str( + CustomExtensionAttributeError( + "Extension attribute '' should be between 1 and 20 characters long" + ) + ), + str( + CustomExtensionAttributeError( + "Extension attribute '' should only contain lowercase letters and numbers" + ) + ), ] }, ), ( "thisisaverylongextension", { - "extensions": [ - "Extension attribute 'thisisaverylongextension' should be between 1 and 20 characters long" + "thisisaverylongextension": [ + str( + CustomExtensionAttributeError( + "Extension attribute 'thisisaverylongextension' should be between 1 and 20 characters long" + ) + ) ] }, ), ( "data", { - "extensions": [ - "Extension attribute 'data' is reserved and must not be used" + "data": [ + str( + CustomExtensionAttributeError( + "Extension attribute 'data' is reserved and must not be used" + ) + ) ] }, ), ], ) -def test_custom_extension(extension_name: str, error: dict) -> None: +def test_custom_extension(extension_name: str, expected_error: dict) -> None: with pytest.raises(CloudEventValidationError) as e: CloudEvent( { @@ -184,7 +314,10 @@ def test_custom_extension(extension_name: str, error: dict) -> None: } ) - assert e.value.errors == error + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_error def test_cloud_event_constructor() -> None: From 443aee98e56e3c2e4eb79ca8f760be9a95779c73 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Wed, 13 Nov 2024 17:57:33 +0200 Subject: [PATCH 13/20] chore: Skip type checing for getters of required attributes We can't use TypedDict here becuase it does not allow for arbitrary keys which we need in order to support custom extension attributes. Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index 20b73499..7477de2f 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -223,7 +223,7 @@ def get_id(self) -> str: :return: The ID of the event. """ - return self._attributes["id"] + return self._attributes["id"] # type: ignore def get_source(self) -> str: """ @@ -231,7 +231,7 @@ def get_source(self) -> str: :return: The source of the event. """ - return self._attributes["source"] + return self._attributes["source"] # type: ignore def get_type(self) -> str: """ @@ -239,7 +239,7 @@ def get_type(self) -> str: :return: The type of the event. """ - return self._attributes["type"] + return self._attributes["type"] # type: ignore def get_specversion(self) -> str: """ @@ -247,7 +247,7 @@ def get_specversion(self) -> str: :return: The specversion of the event. """ - return self._attributes["specversion"] + return self._attributes["specversion"] # type: ignore def get_datacontenttype(self) -> Optional[str]: """ From 1d43d688be26bb3aee7c00154f972a062ec07631 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Thu, 14 Nov 2024 09:22:38 +0200 Subject: [PATCH 14/20] fix: missing type Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index 7477de2f..ace19608 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -64,7 +64,7 @@ def _validate_attribute(attributes: dict[str, Any]) -> None: See https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#required-attributes """ - errors: dict[str, list] = defaultdict(list) + errors: dict[str, list[BaseCloudEventException]] = defaultdict(list) errors.update(CloudEvent._validate_required_attributes(attributes)) errors.update(CloudEvent._validate_optional_attributes(attributes)) errors.update(CloudEvent._validate_extension_attributes(attributes)) From 21493e180fee71707d5d5db127a27645c0a468f3 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Thu, 14 Nov 2024 10:47:17 +0200 Subject: [PATCH 15/20] 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 ace19608..5114aac1 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -22,6 +22,7 @@ CloudEventValidationError, CustomExtensionAttributeError, InvalidAttributeTypeError, + InvalidAttributeValueError, MissingRequiredAttributeError, ) @@ -84,41 +85,37 @@ def _validate_required_attributes( 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 @@ def _validate_optional_attributes( 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 @@ def _validate_extension_attributes( 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 0d8680db..539c0317 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 @@ def __init__(self, errors: dict[str, list[BaseCloudEventException]]) -> None: :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 @@ def __str__(self) -> str: return f"{super().__str__()}: {', '.join(error_messages)}" -class MissingRequiredAttributeError(BaseCloudEventException): +class MissingRequiredAttributeError(BaseCloudEventException, ValueError): + """ + Raised for attributes that are required to be present by the specification. + """ + + def __init__(self, attribute_name: str) -> None: + super().__init__(f"Missing required attribute: '{attribute_name}'") + + +class CustomExtensionAttributeError(BaseCloudEventException, ValueError): """ - Exception for missing required attribute. + Raised when a custom extension attribute violates naming conventions. """ - def __init__(self, missing: str) -> None: - super().__init__(f"Missing required attribute: '{missing}'") + def __init__(self, extension_attribute: str, msg: str) -> None: + self.extension_attribute = extension_attribute + super().__init__(msg) -class CustomExtensionAttributeError(BaseCloudEventException): +class InvalidAttributeTypeError(BaseCloudEventException, TypeError): """ - Exception for invalid custom extension names. + 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 InvalidAttributeTypeError(BaseCloudEventException): + +class InvalidAttributeValueError(BaseCloudEventException, ValueError): """ - Exception for invalid attribute type. + 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 1ee9886a..acd3fd2b 100644 --- a/tests/test_core/test_v1/test_event.py +++ b/tests/test_core/test_v1/test_event.py @@ -22,6 +22,7 @@ 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", ) ) ] From 68337f9adc666ad9959233cec5b05e3504eb3869 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Thu, 14 Nov 2024 12:22:38 +0200 Subject: [PATCH 16/20] fix: str representation for validation error Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cloudevents/core/v1/exceptions.py b/src/cloudevents/core/v1/exceptions.py index 539c0317..7eacc9d0 100644 --- a/src/cloudevents/core/v1/exceptions.py +++ b/src/cloudevents/core/v1/exceptions.py @@ -32,7 +32,7 @@ def __init__(self, errors: dict[str, list[BaseCloudEventException]]) -> None: def __str__(self) -> str: error_messages = [ - f"{key}: {', '.join(str(value))}" for key, value in self.errors.items() + f"{key}: {', '.join(str(e) for e in value)}" for key, value in self.errors.items() ] return f"{super().__str__()}: {', '.join(error_messages)}" From d0bba86277e7013437f30893e88a1ae10e0007f0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 14 Nov 2024 10:24:26 +0000 Subject: [PATCH 17/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/cloudevents/core/v1/exceptions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cloudevents/core/v1/exceptions.py b/src/cloudevents/core/v1/exceptions.py index 7eacc9d0..3b151fb3 100644 --- a/src/cloudevents/core/v1/exceptions.py +++ b/src/cloudevents/core/v1/exceptions.py @@ -32,7 +32,8 @@ def __init__(self, errors: dict[str, list[BaseCloudEventException]]) -> None: def __str__(self) -> str: error_messages = [ - f"{key}: {', '.join(str(e) for e in value)}" for key, value in self.errors.items() + f"{key}: {', '.join(str(e) for e in value)}" + for key, value in self.errors.items() ] return f"{super().__str__()}: {', '.join(error_messages)}" From 599d05c377eb4dc4b783d6a1647e00e37eadaf35 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Thu, 14 Nov 2024 20:59:13 +0200 Subject: [PATCH 18/20] fix: Fix missing type definitions Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 78 ++++++++++++++++++--------- src/cloudevents/core/v1/exceptions.py | 12 ++--- 2 files changed, 58 insertions(+), 32 deletions(-) diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index 5114aac1..b56ef416 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -54,7 +54,7 @@ def __init__(self, attributes: dict[str, Any], data: Optional[dict] = None) -> N :raises ValueError: If any of the required attributes are missing or have invalid values. :raises TypeError: If any of the attributes have invalid types. """ - self._validate_attribute(attributes) + self._validate_attribute(attributes=attributes) self._attributes: dict[str, Any] = attributes self._data: Optional[dict] = data @@ -66,9 +66,9 @@ def _validate_attribute(attributes: dict[str, Any]) -> None: See https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#required-attributes """ errors: dict[str, list[BaseCloudEventException]] = defaultdict(list) - errors.update(CloudEvent._validate_required_attributes(attributes)) - errors.update(CloudEvent._validate_optional_attributes(attributes)) - errors.update(CloudEvent._validate_extension_attributes(attributes)) + errors.update(CloudEvent._validate_required_attributes(attributes=attributes)) + errors.update(CloudEvent._validate_optional_attributes(attributes=attributes)) + errors.update(CloudEvent._validate_extension_attributes(attributes=attributes)) if errors: raise CloudEventValidationError(dict(errors)) @@ -88,33 +88,46 @@ def _validate_required_attributes( errors["id"].append(MissingRequiredAttributeError(attribute_name="id")) if attributes.get("id") is None: errors["id"].append( - InvalidAttributeValueError("id", "Attribute 'id' must not be None") + InvalidAttributeValueError( + attribute_name="id", msg="Attribute 'id' must not be None" + ) ) if not isinstance(attributes.get("id"), str): - errors["id"].append(InvalidAttributeTypeError("id", str)) + errors["id"].append( + InvalidAttributeTypeError(attribute_name="id", expected_type=str) + ) if "source" not in attributes: errors["source"].append( MissingRequiredAttributeError(attribute_name="source") ) if not isinstance(attributes.get("source"), str): - errors["source"].append(InvalidAttributeTypeError("source", str)) + errors["source"].append( + InvalidAttributeTypeError(attribute_name="source", expected_type=str) + ) if "type" not in attributes: errors["type"].append(MissingRequiredAttributeError(attribute_name="type")) if not isinstance(attributes.get("type"), str): - errors["type"].append(InvalidAttributeTypeError("type", str)) + errors["type"].append( + InvalidAttributeTypeError(attribute_name="type", expected_type=str) + ) if "specversion" not in attributes: errors["specversion"].append( MissingRequiredAttributeError(attribute_name="specversion") ) if not isinstance(attributes.get("specversion"), str): - errors["specversion"].append(InvalidAttributeTypeError("specversion", str)) + errors["specversion"].append( + InvalidAttributeTypeError( + attribute_name="specversion", expected_type=str + ) + ) if attributes.get("specversion") != "1.0": errors["specversion"].append( InvalidAttributeValueError( - "specversion", "Attribute 'specversion' must be '1.0'" + attribute_name="specversion", + msg="Attribute 'specversion' must be '1.0'", ) ) return errors @@ -133,43 +146,58 @@ def _validate_optional_attributes( if "time" in attributes: if not isinstance(attributes["time"], datetime): - errors["time"].append(InvalidAttributeTypeError("time", datetime)) + errors["time"].append( + InvalidAttributeTypeError( + attribute_name="time", expected_type=datetime + ) + ) if hasattr(attributes["time"], "tzinfo") and not attributes["time"].tzinfo: errors["time"].append( InvalidAttributeValueError( - "time", "Attribute 'time' must be timezone aware" + attribute_name="time", + msg="Attribute 'time' must be timezone aware", ) ) if "subject" in attributes: if not isinstance(attributes["subject"], str): - errors["subject"].append(InvalidAttributeTypeError("subject", str)) + errors["subject"].append( + InvalidAttributeTypeError( + attribute_name="subject", expected_type=str + ) + ) if not attributes["subject"]: errors["subject"].append( InvalidAttributeValueError( - "subject", "Attribute 'subject' must not be empty" + attribute_name="subject", + msg="Attribute 'subject' must not be empty", ) ) if "datacontenttype" in attributes: if not isinstance(attributes["datacontenttype"], str): errors["datacontenttype"].append( - InvalidAttributeTypeError("datacontenttype", str) + InvalidAttributeTypeError( + attribute_name="datacontenttype", expected_type=str + ) ) if not attributes["datacontenttype"]: errors["datacontenttype"].append( InvalidAttributeValueError( - "datacontenttype", - "Attribute 'datacontenttype' must not be empty", + attribute_name="datacontenttype", + msg="Attribute 'datacontenttype' must not be empty", ) ) if "dataschema" in attributes: if not isinstance(attributes["dataschema"], str): errors["dataschema"].append( - InvalidAttributeTypeError("dataschema", str) + InvalidAttributeTypeError( + attribute_name="dataschema", expected_type=str + ) ) if not attributes["dataschema"]: errors["dataschema"].append( InvalidAttributeValueError( - "dataschema", "Attribute 'dataschema' must not be empty" + attribute_name="dataschema", + msg="Attribute 'dataschema' must not be empty", ) ) return errors @@ -194,22 +222,22 @@ def _validate_extension_attributes( if extension_attribute == "data": errors[extension_attribute].append( CustomExtensionAttributeError( - extension_attribute, - "Extension attribute 'data' is reserved and must not be used", + attribute_name=extension_attribute, + msg="Extension attribute 'data' is reserved and must not be used", ) ) if not (1 <= len(extension_attribute) <= 20): errors[extension_attribute].append( CustomExtensionAttributeError( - extension_attribute, - f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long", + attribute_name=extension_attribute, + msg=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( - extension_attribute, - f"Extension attribute '{extension_attribute}' should only contain lowercase letters and numbers", + attribute_name=extension_attribute, + msg=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 3b151fb3..8f115170 100644 --- a/src/cloudevents/core/v1/exceptions.py +++ b/src/cloudevents/core/v1/exceptions.py @@ -14,8 +14,6 @@ class BaseCloudEventException(Exception): """A CloudEvent generic exception.""" - pass - class CloudEventValidationError(BaseCloudEventException): """ @@ -31,7 +29,7 @@ def __init__(self, errors: dict[str, list[BaseCloudEventException]]) -> None: self.errors: dict[str, list[BaseCloudEventException]] = errors def __str__(self) -> str: - error_messages = [ + error_messages: list[str] = [ f"{key}: {', '.join(str(e) for e in value)}" for key, value in self.errors.items() ] @@ -52,8 +50,8 @@ class CustomExtensionAttributeError(BaseCloudEventException, ValueError): Raised when a custom extension attribute violates naming conventions. """ - def __init__(self, extension_attribute: str, msg: str) -> None: - self.extension_attribute = extension_attribute + def __init__(self, attribute_name: str, msg: str) -> None: + self.attribute_name: str = attribute_name super().__init__(msg) @@ -63,7 +61,7 @@ class InvalidAttributeTypeError(BaseCloudEventException, TypeError): """ def __init__(self, attribute_name: str, expected_type: type) -> None: - self.attribute_name = attribute_name + self.attribute_name: str = attribute_name super().__init__(f"Attribute '{attribute_name}' must be a {expected_type}") @@ -73,5 +71,5 @@ class InvalidAttributeValueError(BaseCloudEventException, ValueError): """ def __init__(self, attribute_name: str, msg: str) -> None: - self.attribute_name = attribute_name + self.attribute_name: str = attribute_name super().__init__(msg) From 43f1d0c0e7cb9907b519165053c91f7bbcf9fb65 Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Thu, 14 Nov 2024 21:05:56 +0200 Subject: [PATCH 19/20] small fix Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/exceptions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cloudevents/core/v1/exceptions.py b/src/cloudevents/core/v1/exceptions.py index 8f115170..ba6b63ae 100644 --- a/src/cloudevents/core/v1/exceptions.py +++ b/src/cloudevents/core/v1/exceptions.py @@ -42,6 +42,7 @@ class MissingRequiredAttributeError(BaseCloudEventException, ValueError): """ def __init__(self, attribute_name: str) -> None: + self.attribute_name: str = attribute_name super().__init__(f"Missing required attribute: '{attribute_name}'") From 7d180987ac9dc156876a451fd263417a6babbf8f Mon Sep 17 00:00:00 2001 From: Tudor Plugaru Date: Thu, 14 Nov 2024 21:06:51 +0200 Subject: [PATCH 20/20] remove cast of defaultdict to dict Signed-off-by: Tudor Plugaru --- src/cloudevents/core/v1/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index b56ef416..043670b5 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -70,7 +70,7 @@ def _validate_attribute(attributes: dict[str, Any]) -> None: errors.update(CloudEvent._validate_optional_attributes(attributes=attributes)) errors.update(CloudEvent._validate_extension_attributes(attributes=attributes)) if errors: - raise CloudEventValidationError(dict(errors)) + raise CloudEventValidationError(errors=errors) @staticmethod def _validate_required_attributes(