diff --git a/CHANGELOG.md b/CHANGELOG.md index 070e71e5..6515c94d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0.alpha5] + +### Changed + +- Improved spec compatibility of attributes processing. ([#275]) + ## [2.0.0.alpha4] ### Changed @@ -317,3 +323,4 @@ CloudEvents v2 is a rewrite with ongoing development ([#271]) [#249]: https://github.com/cloudevents/sdk-python/pull/249 [#271]: https://github.com/cloudevents/sdk-python/pull/271 [#273]: https://github.com/cloudevents/sdk-python/pull/273 +[#275]: https://github.com/cloudevents/sdk-python/pull/275 diff --git a/src/cloudevents/__init__.py b/src/cloudevents/__init__.py index 8276d564..edd4fbd1 100644 --- a/src/cloudevents/__init__.py +++ b/src/cloudevents/__init__.py @@ -12,4 +12,4 @@ # License for the specific language governing permissions and limitations # under the License. -__version__ = "2.0.0-alpha4" +__version__ = "2.0.0-alpha5" diff --git a/src/cloudevents/core/v03/event.py b/src/cloudevents/core/v03/event.py index ba91dc83..4d51c3ed 100644 --- a/src/cloudevents/core/v03/event.py +++ b/src/cloudevents/core/v03/event.py @@ -99,10 +99,10 @@ def _validate_required_attributes( if "id" not in attributes: errors["id"].append(MissingRequiredAttributeError(attribute_name="id")) - if attributes.get("id") is None: + if not attributes.get("id"): errors["id"].append( InvalidAttributeValueError( - attribute_name="id", msg="Attribute 'id' must not be None" + attribute_name="id", msg="Attribute 'id' must not be None or empty" ) ) if not isinstance(attributes.get("id"), str): @@ -114,6 +114,13 @@ def _validate_required_attributes( errors["source"].append( MissingRequiredAttributeError(attribute_name="source") ) + if not attributes.get("source"): + errors["source"].append( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ) if not isinstance(attributes.get("source"), str): errors["source"].append( InvalidAttributeTypeError(attribute_name="source", expected_type=str) @@ -121,6 +128,13 @@ def _validate_required_attributes( if "type" not in attributes: errors["type"].append(MissingRequiredAttributeError(attribute_name="type")) + if not attributes.get("type"): + errors["type"].append( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ) if not isinstance(attributes.get("type"), str): errors["type"].append( InvalidAttributeTypeError(attribute_name="type", expected_type=str) @@ -253,11 +267,11 @@ def _validate_extension_attributes( msg="Extension attribute 'data' is reserved and must not be used", ) ) - if not (1 <= len(extension_attribute) <= 20): + if not (1 <= len(extension_attribute)): errors[extension_attribute].append( CustomExtensionAttributeError( attribute_name=extension_attribute, - msg=f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long", + msg=f"Extension attribute name must be at least 1 character long but was '{extension_attribute}'", ) ) if not re.match(r"^[a-z0-9]+$", extension_attribute): diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index b013fbfb..a71a3d58 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -98,10 +98,10 @@ def _validate_required_attributes( if "id" not in attributes: errors["id"].append(MissingRequiredAttributeError(attribute_name="id")) - if attributes.get("id") is None: + if not attributes.get("id"): errors["id"].append( InvalidAttributeValueError( - attribute_name="id", msg="Attribute 'id' must not be None" + attribute_name="id", msg="Attribute 'id' must not be None or empty" ) ) if not isinstance(attributes.get("id"), str): @@ -113,6 +113,13 @@ def _validate_required_attributes( errors["source"].append( MissingRequiredAttributeError(attribute_name="source") ) + if not attributes.get("source"): + errors["source"].append( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ) if not isinstance(attributes.get("source"), str): errors["source"].append( InvalidAttributeTypeError(attribute_name="source", expected_type=str) @@ -120,6 +127,13 @@ def _validate_required_attributes( if "type" not in attributes: errors["type"].append(MissingRequiredAttributeError(attribute_name="type")) + if not attributes.get("type"): + errors["type"].append( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ) if not isinstance(attributes.get("type"), str): errors["type"].append( InvalidAttributeTypeError(attribute_name="type", expected_type=str) @@ -238,11 +252,11 @@ def _validate_extension_attributes( msg="Extension attribute 'data' is reserved and must not be used", ) ) - if not (1 <= len(extension_attribute) <= 20): + if not (1 <= len(extension_attribute)): errors[extension_attribute].append( CustomExtensionAttributeError( attribute_name=extension_attribute, - msg=f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long", + msg=f"Extension attribute name must be at least 1 character long but was '{extension_attribute}'", ) ) if not re.match(r"^[a-z0-9]+$", extension_attribute): diff --git a/tests/test_core/test_v03/test_event.py b/tests/test_core/test_v03/test_event.py index 05e0e796..950308ed 100644 --- a/tests/test_core/test_v03/test_event.py +++ b/tests/test_core/test_v03/test_event.py @@ -34,10 +34,22 @@ def test_missing_required_attributes() -> None: expected_errors = { "source": [ str(MissingRequiredAttributeError("source")), + str( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ), str(InvalidAttributeTypeError("source", str)), ], "type": [ str(MissingRequiredAttributeError("type")), + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ), str(InvalidAttributeTypeError("type", str)), ], } @@ -277,40 +289,139 @@ def test_schemaurl_validation(schemaurl: Any, expected_error: dict) -> None: @pytest.mark.parametrize( - "extension_name,expected_error", + "attributes,expected_errors", [ ( - "", + {"id": "", "source": "/", "type": "test"}, { - "": [ + "id": [ str( - CustomExtensionAttributeError( - "", - "Extension attribute '' should be between 1 and 20 characters long", + InvalidAttributeValueError( + attribute_name="id", + msg="Attribute 'id' must not be None or empty", + ) + ) + ] + }, + ), + ( + {"id": None, "source": "/", "type": "test"}, + { + "id": [ + str( + InvalidAttributeValueError( + attribute_name="id", + msg="Attribute 'id' must not be None or empty", ) ), str( - CustomExtensionAttributeError( - "", - "Extension attribute '' should only contain lowercase letters and numbers", + InvalidAttributeTypeError( + attribute_name="id", expected_type=str ) ), ] }, ), ( - "thisisaverylongextension", + {"id": "1", "source": "", "type": "test"}, { - "thisisaverylongextension": [ + "source": [ str( - CustomExtensionAttributeError( - "thisisaverylongextension", - "Extension attribute 'thisisaverylongextension' should be between 1 and 20 characters long", + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ) + ] + }, + ), + ( + {"id": "1", "source": None, "type": "test"}, + { + "source": [ + str( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ), + str( + InvalidAttributeTypeError( + attribute_name="source", expected_type=str + ) + ), + ] + }, + ), + ( + {"id": "1", "source": "/", "type": ""}, + { + "type": [ + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", ) ) ] }, ), + ( + {"id": "1", "source": "/", "type": None}, + { + "type": [ + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ), + str( + InvalidAttributeTypeError( + attribute_name="type", expected_type=str + ) + ), + ] + }, + ), + ], +) +def test_required_attributes_null_or_empty( + attributes: dict[str, Any], expected_errors: dict +) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent(attributes=attributes) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + for key, expected_msgs in expected_errors.items(): + assert key in actual_errors + assert actual_errors[key] == expected_msgs + + +@pytest.mark.parametrize( + "extension_name,expected_error", + [ + ( + "", + { + "": [ + str( + CustomExtensionAttributeError( + "", + "Extension attribute name must be at least 1 character long but was ''", + ) + ), + str( + CustomExtensionAttributeError( + "", + "Extension attribute '' should only contain lowercase letters and numbers", + ) + ), + ] + }, + ), ( "data", { @@ -344,6 +455,21 @@ def test_custom_extension(extension_name: str, expected_error: dict) -> None: assert actual_errors == expected_error +def test_long_extension_attribute_name() -> None: + # Verify that extension attribute names longer than 20 characters are allowed + long_name = "a" * 21 + event = CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "0.3", + long_name: "value", + } + ) + assert event.get_extension(long_name) == "value" + + def test_default_specversion() -> None: event = CloudEvent( attributes={"source": "/source", "type": "test", "id": "1"}, diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py index 126e2732..7d283216 100644 --- a/tests/test_core/test_v1/test_event.py +++ b/tests/test_core/test_v1/test_event.py @@ -34,10 +34,22 @@ def test_missing_required_attributes() -> None: expected_errors = { "source": [ str(MissingRequiredAttributeError("source")), + str( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ), str(InvalidAttributeTypeError("source", str)), ], "type": [ str(MissingRequiredAttributeError("type")), + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ), str(InvalidAttributeTypeError("type", str)), ], } @@ -213,40 +225,139 @@ def test_dataschema_validation(dataschema: Any, expected_error: dict) -> None: @pytest.mark.parametrize( - "extension_name,expected_error", + "attributes,expected_errors", [ ( - "", + {"id": "", "source": "/", "type": "test"}, { - "": [ + "id": [ str( - CustomExtensionAttributeError( - "", - "Extension attribute '' should be between 1 and 20 characters long", + InvalidAttributeValueError( + attribute_name="id", + msg="Attribute 'id' must not be None or empty", + ) + ) + ] + }, + ), + ( + {"id": None, "source": "/", "type": "test"}, + { + "id": [ + str( + InvalidAttributeValueError( + attribute_name="id", + msg="Attribute 'id' must not be None or empty", ) ), str( - CustomExtensionAttributeError( - "", - "Extension attribute '' should only contain lowercase letters and numbers", + InvalidAttributeTypeError( + attribute_name="id", expected_type=str ) ), ] }, ), ( - "thisisaverylongextension", + {"id": "1", "source": "", "type": "test"}, { - "thisisaverylongextension": [ + "source": [ str( - CustomExtensionAttributeError( - "thisisaverylongextension", - "Extension attribute 'thisisaverylongextension' should be between 1 and 20 characters long", + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", ) ) ] }, ), + ( + {"id": "1", "source": None, "type": "test"}, + { + "source": [ + str( + InvalidAttributeValueError( + attribute_name="source", + msg="Attribute 'source' must not be None or empty", + ) + ), + str( + InvalidAttributeTypeError( + attribute_name="source", expected_type=str + ) + ), + ] + }, + ), + ( + {"id": "1", "source": "/", "type": ""}, + { + "type": [ + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ) + ] + }, + ), + ( + {"id": "1", "source": "/", "type": None}, + { + "type": [ + str( + InvalidAttributeValueError( + attribute_name="type", + msg="Attribute 'type' must not be None or empty", + ) + ), + str( + InvalidAttributeTypeError( + attribute_name="type", expected_type=str + ) + ), + ] + }, + ), + ], +) +def test_required_attributes_null_or_empty( + attributes: dict[str, Any], expected_errors: dict +) -> None: + with pytest.raises(CloudEventValidationError) as e: + CloudEvent(attributes=attributes) + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + for key, expected_msgs in expected_errors.items(): + assert key in actual_errors + assert actual_errors[key] == expected_msgs + + +@pytest.mark.parametrize( + "extension_name,expected_error", + [ + ( + "", + { + "": [ + str( + CustomExtensionAttributeError( + "", + "Extension attribute name must be at least 1 character long but was ''", + ) + ), + str( + CustomExtensionAttributeError( + "", + "Extension attribute '' should only contain lowercase letters and numbers", + ) + ), + ] + }, + ), ( "data", { @@ -280,6 +391,21 @@ def test_custom_extension(extension_name: str, expected_error: dict) -> None: assert actual_errors == expected_error +def test_long_extension_attribute_name() -> None: + # Verify that extension attribute names longer than 20 characters are allowed + long_name = "a" * 21 + event = CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", + long_name: "value", + } + ) + assert event.get_extension(long_name) == "value" + + def test_default_specversion() -> None: event = CloudEvent( attributes={"source": "/source", "type": "test", "id": "1"},