diff --git a/pyproject.toml b/pyproject.toml index 52fb4bb..e587d7b 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 = [ diff --git a/src/cloudevents/core/v1/__init__.py b/src/cloudevents/core/v1/__init__.py new file mode 100644 index 0000000..896dfe1 --- /dev/null +++ b/src/cloudevents/core/v1/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000..043670b --- /dev/null +++ b/src/cloudevents/core/v1/event.py @@ -0,0 +1,324 @@ +# 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. + +import re +from collections import defaultdict +from datetime import datetime +from typing import Any, Final, Optional + +from cloudevents.core.v1.exceptions import ( + BaseCloudEventException, + CloudEventValidationError, + CustomExtensionAttributeError, + InvalidAttributeTypeError, + InvalidAttributeValueError, + MissingRequiredAttributeError, +) + +REQUIRED_ATTRIBUTES: Final[list[str]] = ["id", "source", "type", "specversion"] +OPTIONAL_ATTRIBUTES: Final[list[str]] = [ + "datacontenttype", + "dataschema", + "subject", + "time", +] + + +class CloudEvent: + """ + 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. + :param data: The payload of the CloudEvent instance. + + :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=attributes) + self._attributes: dict[str, Any] = attributes + self._data: Optional[dict] = data + + @staticmethod + def _validate_attribute(attributes: dict[str, Any]) -> None: + """ + Validates the attributes of the CloudEvent as per the CloudEvents specification. + + 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=attributes)) + errors.update(CloudEvent._validate_optional_attributes(attributes=attributes)) + errors.update(CloudEvent._validate_extension_attributes(attributes=attributes)) + if errors: + raise CloudEventValidationError(errors=errors) + + @staticmethod + def _validate_required_attributes( + attributes: dict[str, Any], + ) -> 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 = defaultdict(list) + + if "id" not in attributes: + errors["id"].append(MissingRequiredAttributeError(attribute_name="id")) + if attributes.get("id") is None: + errors["id"].append( + InvalidAttributeValueError( + attribute_name="id", msg="Attribute 'id' must not be None" + ) + ) + if not isinstance(attributes.get("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(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(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( + attribute_name="specversion", expected_type=str + ) + ) + if attributes.get("specversion") != "1.0": + errors["specversion"].append( + InvalidAttributeValueError( + attribute_name="specversion", + msg="Attribute 'specversion' must be '1.0'", + ) + ) + return errors + + @staticmethod + def _validate_optional_attributes( + attributes: dict[str, Any], + ) -> 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 = defaultdict(list) + + if "time" in attributes: + if not isinstance(attributes["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( + attribute_name="time", + msg="Attribute 'time' must be timezone aware", + ) + ) + if "subject" in attributes: + if not isinstance(attributes["subject"], str): + errors["subject"].append( + InvalidAttributeTypeError( + attribute_name="subject", expected_type=str + ) + ) + if not attributes["subject"]: + errors["subject"].append( + InvalidAttributeValueError( + attribute_name="subject", + msg="Attribute 'subject' must not be empty", + ) + ) + if "datacontenttype" in attributes: + if not isinstance(attributes["datacontenttype"], str): + errors["datacontenttype"].append( + InvalidAttributeTypeError( + attribute_name="datacontenttype", expected_type=str + ) + ) + if not attributes["datacontenttype"]: + errors["datacontenttype"].append( + InvalidAttributeValueError( + attribute_name="datacontenttype", + msg="Attribute 'datacontenttype' must not be empty", + ) + ) + if "dataschema" in attributes: + if not isinstance(attributes["dataschema"], str): + errors["dataschema"].append( + InvalidAttributeTypeError( + attribute_name="dataschema", expected_type=str + ) + ) + if not attributes["dataschema"]: + errors["dataschema"].append( + InvalidAttributeValueError( + attribute_name="dataschema", + msg="Attribute 'dataschema' must not be empty", + ) + ) + return errors + + @staticmethod + def _validate_extension_attributes( + attributes: dict[str, Any], + ) -> dict[str, list[BaseCloudEventException]]: + """ + Validates the extension attributes. + + :param attributes: The attributes of the CloudEvent instance. + :return: A dictionary of validation error messages. + """ + errors = defaultdict(list) + 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": + errors[extension_attribute].append( + CustomExtensionAttributeError( + 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( + 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( + attribute_name=extension_attribute, + msg=f"Extension attribute '{extension_attribute}' should only contain lowercase letters and numbers", + ) + ) + return errors + + def get_id(self) -> str: + """ + Retrieve the ID of the event. + + :return: The ID of the event. + """ + return self._attributes["id"] # type: ignore + + def get_source(self) -> str: + """ + Retrieve the source of the event. + + :return: The source of the event. + """ + return self._attributes["source"] # type: ignore + + def get_type(self) -> str: + """ + Retrieve the type of the event. + + :return: The type of the event. + """ + return self._attributes["type"] # type: ignore + + def get_specversion(self) -> str: + """ + Retrieve the specversion of the event. + + :return: The specversion of the event. + """ + return self._attributes["specversion"] # type: ignore + + 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 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]: + """ + Retrieve data of the event. + + :return: The data of the event. + """ + return self._data diff --git a/src/cloudevents/core/v1/exceptions.py b/src/cloudevents/core/v1/exceptions.py new file mode 100644 index 0000000..ba6b63a --- /dev/null +++ b/src/cloudevents/core/v1/exceptions.py @@ -0,0 +1,76 @@ +# 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 BaseCloudEventException(Exception): + """A CloudEvent generic exception.""" + + +class CloudEventValidationError(BaseCloudEventException): + """ + Holds validation errors aggregated during a CloudEvent creation. + """ + + 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__("Failed to create CloudEvent due to the validation errors:") + self.errors: dict[str, list[BaseCloudEventException]] = errors + + def __str__(self) -> str: + error_messages: list[str] = [ + f"{key}: {', '.join(str(e) for e in value)}" + for key, value in self.errors.items() + ] + return f"{super().__str__()}: {', '.join(error_messages)}" + + +class MissingRequiredAttributeError(BaseCloudEventException, ValueError): + """ + Raised for attributes that are required to be present by the specification. + """ + + def __init__(self, attribute_name: str) -> None: + self.attribute_name: str = attribute_name + super().__init__(f"Missing required attribute: '{attribute_name}'") + + +class CustomExtensionAttributeError(BaseCloudEventException, ValueError): + """ + Raised when a custom extension attribute violates naming conventions. + """ + + def __init__(self, attribute_name: str, msg: str) -> None: + self.attribute_name: str = attribute_name + super().__init__(msg) + + +class InvalidAttributeTypeError(BaseCloudEventException, TypeError): + """ + Raised when an attribute has an unsupported type. + """ + + def __init__(self, attribute_name: str, expected_type: type) -> None: + self.attribute_name: str = attribute_name + super().__init__(f"Attribute '{attribute_name}' must be a {expected_type}") + + +class InvalidAttributeValueError(BaseCloudEventException, ValueError): + """ + Raised when an attribute has an invalid value. + """ + + def __init__(self, attribute_name: str, msg: str) -> None: + self.attribute_name: str = attribute_name + super().__init__(msg) diff --git a/tests/test_core/__init__.py b/tests/test_core/__init__.py new file mode 100644 index 0000000..8043675 --- /dev/null +++ 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 new file mode 100644 index 0000000..8043675 --- /dev/null +++ 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 new file mode 100644 index 0000000..acd3fd2 --- /dev/null +++ b/tests/test_core/test_v1/test_event.py @@ -0,0 +1,333 @@ +# 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 datetime import datetime, timezone +from typing import Any + +import pytest + +from cloudevents.core.v1.event import CloudEvent +from cloudevents.core.v1.exceptions import ( + CloudEventValidationError, + CustomExtensionAttributeError, + InvalidAttributeTypeError, + InvalidAttributeValueError, + MissingRequiredAttributeError, +) + + +def test_missing_required_attributes() -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent({}) + + expected_errors = { + "id": [ + str(MissingRequiredAttributeError("id")), + str(InvalidAttributeValueError("id", "Attribute 'id' must not be None")), + str(InvalidAttributeTypeError("id", str)), + ], + "source": [ + str(MissingRequiredAttributeError("source")), + str(InvalidAttributeTypeError("source", str)), + ], + "type": [ + str(MissingRequiredAttributeError("type")), + str(InvalidAttributeTypeError("type", str)), + ], + "specversion": [ + str(MissingRequiredAttributeError("specversion")), + str(InvalidAttributeTypeError("specversion", str)), + str( + InvalidAttributeValueError( + "specversion", "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,expected_error", + [ + ( + "2023-10-25T17:09:19.736166Z", + {"time": [str(InvalidAttributeTypeError("time", datetime))]}, + ), + ( + datetime(2023, 10, 25, 17, 9, 19, 736166), + { + "time": [ + str( + InvalidAttributeValueError( + "time", "Attribute 'time' must be timezone aware" + ) + ) + ] + }, + ), + ( + 1, + {"time": [str(InvalidAttributeTypeError("time", datetime))]}, + ), + ], +) +def test_time_validation(time: Any, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "time": time, + } + ) + 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,expected_error", + [ + ( + 1234, + {"subject": [str(InvalidAttributeTypeError("subject", str))]}, + ), + ( + "", + { + "subject": [ + str( + InvalidAttributeValueError( + "subject", "Attribute 'subject' must not be empty" + ) + ) + ] + }, + ), + ], +) +def test_subject_validation(subject: Any, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "subject": subject, + } + ) + + 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,expected_error", + [ + ( + 1234, + { + "datacontenttype": [ + str(InvalidAttributeTypeError("datacontenttype", str)) + ] + }, + ), + ( + "", + { + "datacontenttype": [ + str( + InvalidAttributeValueError( + "datacontenttype", + "Attribute 'datacontenttype' must not be empty", + ) + ) + ] + }, + ), + ], +) +def test_datacontenttype_validation(datacontenttype: Any, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "datacontenttype": datacontenttype, + } + ) + + 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,expected_error", + [ + ( + 1234, + {"dataschema": [str(InvalidAttributeTypeError("dataschema", str))]}, + ), + ( + "", + { + "dataschema": [ + str( + InvalidAttributeValueError( + "dataschema", "Attribute 'dataschema' must not be empty" + ) + ) + ] + }, + ), + ], +) +def test_dataschema_validation(dataschema: Any, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + "dataschema": dataschema, + } + ) + + 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,expected_error", + [ + ( + "", + { + "": [ + str( + CustomExtensionAttributeError( + "", + "Extension attribute '' should be between 1 and 20 characters long", + ) + ), + str( + CustomExtensionAttributeError( + "", + "Extension attribute '' should only contain lowercase letters and numbers", + ) + ), + ] + }, + ), + ( + "thisisaverylongextension", + { + "thisisaverylongextension": [ + str( + CustomExtensionAttributeError( + "thisisaverylongextension", + "Extension attribute 'thisisaverylongextension' should be between 1 and 20 characters long", + ) + ) + ] + }, + ), + ( + "data", + { + "data": [ + str( + CustomExtensionAttributeError( + "data", + "Extension attribute 'data' is reserved and must not be used", + ) + ) + ] + }, + ), + ], +) +def test_custom_extension(extension_name: str, expected_error: dict) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + extension_name: "value", + } + ) + + 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: + 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"} + customextension = "customExtension" + + event = CloudEvent( + attributes={ + "id": id, + "source": source, + "type": type, + "specversion": specversion, + "datacontenttype": datacontenttype, + "dataschema": dataschema, + "subject": subject, + "time": time, + "customextension": customextension, + }, + 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_extension("customextension") == customextension + assert event.get_data() == data