diff --git a/README.md b/README.md index 6682cbcc..7d3e5fb6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ will) break with every update. This SDK current supports the following versions of CloudEvents: - v1.0 +- v0.3 ## Python SDK diff --git a/pyproject.toml b/pyproject.toml index d9c85359..edfc9e06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,7 +114,10 @@ exclude = [ [tool.ruff.lint] ignore = ["E731"] extend-ignore = ["E203"] -select = ["I"] +select = [ + "I", # isort - import sorting + "F401", # unused imports +] [tool.pytest.ini_options] diff --git a/src/cloudevents/core/__init__.py b/src/cloudevents/core/__init__.py index e01d2a11..15e478f0 100644 --- a/src/cloudevents/core/__init__.py +++ b/src/cloudevents/core/__init__.py @@ -13,3 +13,7 @@ # under the License. """This package contains the core functionality of the CloudEvents spec.""" + +# CloudEvents specification version constants +SPECVERSION_V1_0 = "1.0" +SPECVERSION_V0_3 = "0.3" diff --git a/src/cloudevents/core/bindings/amqp.py b/src/cloudevents/core/bindings/amqp.py index bafd8f48..019bc78d 100644 --- a/src/cloudevents/core/bindings/amqp.py +++ b/src/cloudevents/core/bindings/amqp.py @@ -18,7 +18,9 @@ from dateutil.parser import isoparse +from cloudevents.core import SPECVERSION_V1_0 from cloudevents.core.base import BaseCloudEvent, EventFactory +from cloudevents.core.bindings.common import get_event_factory_for_version from cloudevents.core.formats.base import Format # AMQP CloudEvents spec allows both cloudEvents_ and cloudEvents: prefixes @@ -149,11 +151,14 @@ def to_binary(event: BaseCloudEvent, event_format: Format) -> AMQPMessage: def from_binary( message: AMQPMessage, event_format: Format, - event_factory: EventFactory, + event_factory: EventFactory | None = None, ) -> BaseCloudEvent: """ Parse an AMQP binary content mode message to a CloudEvent. + Auto-detects the CloudEvents version from the application properties + and uses the appropriate event factory if not explicitly provided. + Extracts CloudEvent attributes from AMQP application properties with either 'cloudEvents_' or 'cloudEvents:' prefix (per AMQP CloudEvents spec), and treats the AMQP 'content-type' property as the 'datacontenttype' attribute. The @@ -200,6 +205,11 @@ def from_binary( if CONTENT_TYPE_PROPERTY in message.properties: attributes["datacontenttype"] = message.properties[CONTENT_TYPE_PROPERTY] + # Auto-detect version if factory not provided + if event_factory is None: + specversion = attributes.get("specversion", SPECVERSION_V1_0) + event_factory = get_event_factory_for_version(specversion) + datacontenttype = attributes.get("datacontenttype") data = event_format.read_data(message.application_data, datacontenttype) @@ -248,7 +258,7 @@ def to_structured(event: BaseCloudEvent, event_format: Format) -> AMQPMessage: def from_structured( message: AMQPMessage, event_format: Format, - event_factory: EventFactory, + event_factory: EventFactory | None = None, ) -> BaseCloudEvent: """ Parse an AMQP structured content mode message to a CloudEvent. @@ -257,33 +267,44 @@ def from_structured( specified format. Any cloudEvents_-prefixed application properties are ignored as the application-data contains all event metadata. + If event_factory is not provided, version detection is delegated to the format + implementation, which will auto-detect based on the 'specversion' field. + Example: >>> from cloudevents.core.v1.event import CloudEvent >>> from cloudevents.core.formats.json import JSONFormat >>> + >>> # Explicit factory >>> message = AMQPMessage( ... properties={"content-type": "application/cloudevents+json"}, ... application_properties={}, ... application_data=b'{"type": "com.example.test", "source": "/test", ...}' ... ) >>> event = from_structured(message, JSONFormat(), CloudEvent) + >>> + >>> # Auto-detect version + >>> event = from_structured(message, JSONFormat()) :param message: AMQPMessage to parse :param event_format: Format implementation for deserialization - :param event_factory: Factory function to create CloudEvent instances + :param event_factory: Factory function to create CloudEvent instances. + If None, the format will auto-detect the version. :return: CloudEvent instance """ + # Delegate version detection to format layer return event_format.read(event_factory, message.application_data) def from_amqp( message: AMQPMessage, event_format: Format, - event_factory: EventFactory, + event_factory: EventFactory | None = None, ) -> BaseCloudEvent: """ Parse an AMQP message to a CloudEvent with automatic mode detection. + Auto-detects CloudEvents version and uses appropriate event factory if not provided. + Automatically detects whether the message uses binary or structured content mode: - If content-type starts with "application/cloudevents" → structured mode - Otherwise → binary mode @@ -313,7 +334,7 @@ def from_amqp( :param message: AMQPMessage to parse :param event_format: Format implementation for deserialization - :param event_factory: Factory function to create CloudEvent instances + :param event_factory: Factory function to create CloudEvent instances (auto-detected if None) :return: CloudEvent instance """ content_type = message.properties.get(CONTENT_TYPE_PROPERTY, "") diff --git a/src/cloudevents/core/bindings/common.py b/src/cloudevents/core/bindings/common.py index 7fced491..98190bb4 100644 --- a/src/cloudevents/core/bindings/common.py +++ b/src/cloudevents/core/bindings/common.py @@ -25,6 +25,11 @@ from dateutil.parser import isoparse +from cloudevents.core import SPECVERSION_V0_3 +from cloudevents.core.base import EventFactory +from cloudevents.core.v03.event import CloudEvent as CloudEventV03 +from cloudevents.core.v1.event import CloudEvent + TIME_ATTR: Final[str] = "time" CONTENT_TYPE_HEADER: Final[str] = "content-type" DATACONTENTTYPE_ATTR: Final[str] = "datacontenttype" @@ -66,3 +71,19 @@ def decode_header_value(attr_name: str, value: str) -> Any: return isoparse(decoded) return decoded + + +def get_event_factory_for_version(specversion: str) -> EventFactory: + """ + Get the appropriate event factory based on the CloudEvents specification version. + + This function returns the CloudEvent class implementation for the specified + version. Used by protocol bindings for automatic version detection. + + :param specversion: The CloudEvents specification version (e.g., "0.3" or "1.0") + :return: EventFactory for the specified version (defaults to v1.0 for unknown versions) + """ + if specversion == SPECVERSION_V0_3: + return CloudEventV03 + # Default to v1.0 for unknown versions + return CloudEvent diff --git a/src/cloudevents/core/bindings/http.py b/src/cloudevents/core/bindings/http.py index bc501baa..48f96bbc 100644 --- a/src/cloudevents/core/bindings/http.py +++ b/src/cloudevents/core/bindings/http.py @@ -15,16 +15,17 @@ from dataclasses import dataclass from typing import Any, Final +from cloudevents.core import SPECVERSION_V1_0 from cloudevents.core.base import BaseCloudEvent, EventFactory from cloudevents.core.bindings.common import ( CONTENT_TYPE_HEADER, DATACONTENTTYPE_ATTR, decode_header_value, encode_header_value, + get_event_factory_for_version, ) from cloudevents.core.formats.base import Format from cloudevents.core.formats.json import JSONFormat -from cloudevents.core.v1.event import CloudEvent CE_PREFIX: Final[str] = "ce-" @@ -94,11 +95,14 @@ def to_binary(event: BaseCloudEvent, event_format: Format) -> HTTPMessage: def from_binary( message: HTTPMessage, event_format: Format, - event_factory: EventFactory, + event_factory: EventFactory | None = None, ) -> BaseCloudEvent: """ Parse an HTTP binary content mode message to a CloudEvent. + Auto-detects the CloudEvents version from the 'ce-specversion' header + and uses the appropriate event factory if not explicitly provided. + Extracts CloudEvent attributes from ce-prefixed HTTP headers and treats the 'Content-Type' header as the 'datacontenttype' attribute. The HTTP body is parsed as event data according to the content type. @@ -116,7 +120,7 @@ def from_binary( :param message: HTTPMessage to parse :param event_format: Format implementation for data deserialization - :param event_factory: Factory function to create CloudEvent instances + :param event_factory: Factory function to create CloudEvent instances (auto-detected if None) :return: CloudEvent instance """ attributes: dict[str, Any] = {} @@ -130,6 +134,11 @@ def from_binary( elif normalized_name == CONTENT_TYPE_HEADER: attributes[DATACONTENTTYPE_ATTR] = header_value + # Auto-detect version if factory not provided + if event_factory is None: + specversion = attributes.get("specversion", SPECVERSION_V1_0) + event_factory = get_event_factory_for_version(specversion) + datacontenttype = attributes.get(DATACONTENTTYPE_ATTR) data = event_format.read_data(message.body, datacontenttype) @@ -172,7 +181,7 @@ def to_structured(event: BaseCloudEvent, event_format: Format) -> HTTPMessage: def from_structured( message: HTTPMessage, event_format: Format, - event_factory: EventFactory, + event_factory: EventFactory | None = None, ) -> BaseCloudEvent: """ Parse an HTTP structured content mode message to a CloudEvent. @@ -180,32 +189,43 @@ def from_structured( Deserializes the CloudEvent from the HTTP body using the specified format. Any ce-prefixed headers are ignored as the body contains all event metadata. + If event_factory is not provided, version detection is delegated to the format + implementation, which will auto-detect based on the 'specversion' field. + Example: >>> from cloudevents.core.v1.event import CloudEvent >>> from cloudevents.core.formats.json import JSONFormat >>> + >>> # Explicit factory (recommended for performance) >>> message = HTTPMessage( ... headers={"content-type": "application/cloudevents+json"}, ... body=b'{"type": "com.example.test", "source": "/test", ...}' ... ) >>> event = from_structured(message, JSONFormat(), CloudEvent) + >>> + >>> # Auto-detect version (convenient) + >>> event = from_structured(message, JSONFormat()) :param message: HTTPMessage to parse :param event_format: Format implementation for deserialization - :param event_factory: Factory function to create CloudEvent instances + :param event_factory: Factory function to create CloudEvent instances. + If None, the format will auto-detect the version. :return: CloudEvent instance """ + # Delegate version detection to format layer return event_format.read(event_factory, message.body) def from_http( message: HTTPMessage, event_format: Format, - event_factory: EventFactory, + event_factory: EventFactory | None = None, ) -> BaseCloudEvent: """ Parse an HTTP message to a CloudEvent with automatic mode detection. + Auto-detects CloudEvents version and uses appropriate event factory if not provided. + Automatically detects whether the message uses binary or structured content mode: - If any ce- prefixed headers are present → binary mode - Otherwise → structured mode @@ -233,7 +253,7 @@ def from_http( :param message: HTTPMessage to parse :param event_format: Format implementation for deserialization - :param event_factory: Factory function to create CloudEvent instances + :param event_factory: Factory function to create CloudEvent instances (auto-detected if None) :return: CloudEvent instance """ if any(key.lower().startswith(CE_PREFIX) for key in message.headers.keys()): @@ -271,9 +291,11 @@ def to_binary_event( def from_binary_event( message: HTTPMessage, event_format: Format | None = None, -) -> CloudEvent: +) -> BaseCloudEvent: """ - Convenience wrapper for from_binary with JSON format and CloudEvent as defaults. + Convenience wrapper for from_binary with JSON format and auto-detection. + + Auto-detects CloudEvents version (v0.3 or v1.0) from headers. Example: >>> from cloudevents.core.bindings import http @@ -281,11 +303,11 @@ def from_binary_event( :param message: HTTPMessage to parse :param event_format: Format implementation (defaults to JSONFormat) - :return: CloudEvent instance + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) """ if event_format is None: event_format = JSONFormat() - return from_binary(message, event_format, CloudEvent) + return from_binary(message, event_format, None) def to_structured_event( @@ -317,9 +339,11 @@ def to_structured_event( def from_structured_event( message: HTTPMessage, event_format: Format | None = None, -) -> CloudEvent: +) -> BaseCloudEvent: """ - Convenience wrapper for from_structured with JSON format and CloudEvent as defaults. + Convenience wrapper for from_structured with JSON format and auto-detection. + + Auto-detects CloudEvents version (v0.3 or v1.0) from body. Example: >>> from cloudevents.core.bindings import http @@ -327,20 +351,20 @@ def from_structured_event( :param message: HTTPMessage to parse :param event_format: Format implementation (defaults to JSONFormat) - :return: CloudEvent instance + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) """ if event_format is None: event_format = JSONFormat() - return from_structured(message, event_format, CloudEvent) + return from_structured(message, event_format, None) def from_http_event( message: HTTPMessage, event_format: Format | None = None, -) -> CloudEvent: +) -> BaseCloudEvent: """ - Convenience wrapper for from_http with JSON format and CloudEvent as defaults. - Auto-detects binary or structured mode. + Convenience wrapper for from_http with JSON format and auto-detection. + Auto-detects binary or structured mode, and CloudEvents version. Example: >>> from cloudevents.core.bindings import http @@ -348,8 +372,8 @@ def from_http_event( :param message: HTTPMessage to parse :param event_format: Format implementation (defaults to JSONFormat) - :return: CloudEvent instance + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) """ if event_format is None: event_format = JSONFormat() - return from_http(message, event_format, CloudEvent) + return from_http(message, event_format, None) diff --git a/src/cloudevents/core/bindings/kafka.py b/src/cloudevents/core/bindings/kafka.py index c4270584..3320e30f 100644 --- a/src/cloudevents/core/bindings/kafka.py +++ b/src/cloudevents/core/bindings/kafka.py @@ -15,16 +15,17 @@ from dataclasses import dataclass from typing import Any, Callable, Final +from cloudevents.core import SPECVERSION_V1_0 from cloudevents.core.base import BaseCloudEvent, EventFactory from cloudevents.core.bindings.common import ( CONTENT_TYPE_HEADER, DATACONTENTTYPE_ATTR, decode_header_value, encode_header_value, + get_event_factory_for_version, ) from cloudevents.core.formats.base import Format from cloudevents.core.formats.json import JSONFormat -from cloudevents.core.v1.event import CloudEvent CE_PREFIX: Final[str] = "ce_" PARTITIONKEY_ATTR: Final[str] = "partitionkey" @@ -127,11 +128,14 @@ def to_binary( def from_binary( message: KafkaMessage, event_format: Format, - event_factory: EventFactory, + event_factory: EventFactory | None = None, ) -> BaseCloudEvent: """ Parse a Kafka binary content mode message to a CloudEvent. + Auto-detects the CloudEvents version from the 'ce_specversion' header + and uses the appropriate event factory if not explicitly provided. + Extracts CloudEvent attributes from ce_-prefixed Kafka headers and treats the 'content-type' header as the 'datacontenttype' attribute. The Kafka message value is parsed as event data according to the content type. If the message has a key, @@ -176,6 +180,11 @@ def from_binary( ) attributes[PARTITIONKEY_ATTR] = key_value + # Auto-detect version if factory not provided + if event_factory is None: + specversion = attributes.get("specversion", SPECVERSION_V1_0) + event_factory = get_event_factory_for_version(specversion) + datacontenttype = attributes.get(DATACONTENTTYPE_ATTR) data = event_format.read_data(message.value, datacontenttype) @@ -228,7 +237,7 @@ def to_structured( def from_structured( message: KafkaMessage, event_format: Format, - event_factory: EventFactory, + event_factory: EventFactory | None = None, ) -> BaseCloudEvent: """ Parse a Kafka structured content mode message to a CloudEvent. @@ -237,22 +246,31 @@ def from_structured( Any ce_-prefixed headers are ignored as the value contains all event metadata. If the message has a key, it is added as the 'partitionkey' extension attribute. + If event_factory is not provided, version detection is delegated to the format + implementation, which will auto-detect based on the 'specversion' field. + Example: >>> from cloudevents.core.v1.event import CloudEvent >>> from cloudevents.core.formats.json import JSONFormat >>> + >>> # Explicit factory >>> message = KafkaMessage( ... headers={"content-type": b"application/cloudevents+json"}, ... key=b"partition-key-123", ... value=b'{"type": "com.example.test", "source": "/test", ...}' ... ) >>> event = from_structured(message, JSONFormat(), CloudEvent) + >>> + >>> # Auto-detect version + >>> event = from_structured(message, JSONFormat()) :param message: KafkaMessage to parse :param event_format: Format implementation for deserialization - :param event_factory: Factory function to create CloudEvent instances + :param event_factory: Factory function to create CloudEvent instances. + If None, the format will auto-detect the version. :return: CloudEvent instance """ + # Delegate version detection to format layer event = event_format.read(event_factory, message.value) # If message has a key, we need to add it as partitionkey extension attribute @@ -266,7 +284,8 @@ def from_structured( attributes = event.get_attributes() attributes[PARTITIONKEY_ATTR] = key_value data = event.get_data() - event = event_factory(attributes, data) + + event = type(event)(attributes, data) return event @@ -274,11 +293,13 @@ def from_structured( def from_kafka( message: KafkaMessage, event_format: Format, - event_factory: EventFactory, + event_factory: EventFactory | None = None, ) -> BaseCloudEvent: """ Parse a Kafka message to a CloudEvent with automatic mode detection. + Auto-detects CloudEvents version and uses appropriate event factory if not provided. + Automatically detects whether the message uses binary or structured content mode: - If any ce_ prefixed headers are present → binary mode - Otherwise → structured mode @@ -308,7 +329,7 @@ def from_kafka( :param message: KafkaMessage to parse :param event_format: Format implementation for deserialization - :param event_factory: Factory function to create CloudEvent instances + :param event_factory: Factory function to create CloudEvent instances (auto-detected if None) :return: CloudEvent instance """ for header_name in message.headers.keys(): @@ -349,9 +370,11 @@ def to_binary_event( def from_binary_event( message: KafkaMessage, event_format: Format | None = None, -) -> CloudEvent: +) -> BaseCloudEvent: """ - Convenience wrapper for from_binary with JSON format and CloudEvent as defaults. + Convenience wrapper for from_binary with JSON format and auto-detection. + + Auto-detects CloudEvents version (v0.3 or v1.0) from headers. Example: >>> from cloudevents.core.bindings import kafka @@ -359,11 +382,11 @@ def from_binary_event( :param message: KafkaMessage to parse :param event_format: Format implementation (defaults to JSONFormat) - :return: CloudEvent instance + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) """ if event_format is None: event_format = JSONFormat() - return from_binary(message, event_format, CloudEvent) + return from_binary(message, event_format, None) def to_structured_event( @@ -397,9 +420,11 @@ def to_structured_event( def from_structured_event( message: KafkaMessage, event_format: Format | None = None, -) -> CloudEvent: +) -> BaseCloudEvent: """ - Convenience wrapper for from_structured with JSON format and CloudEvent as defaults. + Convenience wrapper for from_structured with JSON format and auto-detection. + + Auto-detects CloudEvents version (v0.3 or v1.0) from message body. Example: >>> from cloudevents.core.bindings import kafka @@ -407,20 +432,20 @@ def from_structured_event( :param message: KafkaMessage to parse :param event_format: Format implementation (defaults to JSONFormat) - :return: CloudEvent instance + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) """ if event_format is None: event_format = JSONFormat() - return from_structured(message, event_format, CloudEvent) + return from_structured(message, event_format, None) def from_kafka_event( message: KafkaMessage, event_format: Format | None = None, -) -> CloudEvent: +) -> BaseCloudEvent: """ - Convenience wrapper for from_kafka with JSON format and CloudEvent as defaults. - Auto-detects binary or structured mode. + Convenience wrapper for from_kafka with JSON format and auto-detection. + Auto-detects binary or structured mode, and CloudEvents version. Example: >>> from cloudevents.core.bindings import kafka @@ -428,8 +453,8 @@ def from_kafka_event( :param message: KafkaMessage to parse :param event_format: Format implementation (defaults to JSONFormat) - :return: CloudEvent instance + :return: CloudEvent instance (v0.3 or v1.0 based on specversion) """ if event_format is None: event_format = JSONFormat() - return from_kafka(message, event_format, CloudEvent) + return from_kafka(message, event_format, None) diff --git a/src/cloudevents/core/v1/exceptions.py b/src/cloudevents/core/exceptions.py similarity index 97% rename from src/cloudevents/core/v1/exceptions.py rename to src/cloudevents/core/exceptions.py index ba6b63ae..c4a186c4 100644 --- a/src/cloudevents/core/v1/exceptions.py +++ b/src/cloudevents/core/exceptions.py @@ -11,6 +11,11 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +""" +Common exceptions for CloudEvents (version-agnostic). +""" + + class BaseCloudEventException(Exception): """A CloudEvent generic exception.""" diff --git a/src/cloudevents/core/formats/base.py b/src/cloudevents/core/formats/base.py index 9cb3523c..ae2d9d0a 100644 --- a/src/cloudevents/core/formats/base.py +++ b/src/cloudevents/core/formats/base.py @@ -29,7 +29,7 @@ class Format(Protocol): def read( self, - event_factory: EventFactory, + event_factory: EventFactory | None, data: str | bytes, ) -> BaseCloudEvent: """ @@ -38,6 +38,7 @@ def read( :param event_factory: A factory function that creates CloudEvent instances from attributes and data. The factory should accept a dictionary of attributes and optional event data (dict, str, or bytes). + If None, the format implementation should auto-detect the version from the data. :param data: The serialized CloudEvent data as a string or bytes. :return: A CloudEvent instance constructed from the deserialized data. :raises ValueError: If the data cannot be parsed or is invalid according to the format. diff --git a/src/cloudevents/core/formats/json.py b/src/cloudevents/core/formats/json.py index 8823e1e4..dcbf7d43 100644 --- a/src/cloudevents/core/formats/json.py +++ b/src/cloudevents/core/formats/json.py @@ -20,6 +20,7 @@ from dateutil.parser import isoparse +from cloudevents.core import SPECVERSION_V0_3, SPECVERSION_V1_0 from cloudevents.core.base import BaseCloudEvent, EventFactory from cloudevents.core.formats.base import Format @@ -49,13 +50,18 @@ class JSONFormat(Format): def read( self, - event_factory: EventFactory, + event_factory: EventFactory | None, data: str | bytes, ) -> BaseCloudEvent: """ Read a CloudEvent from a JSON formatted byte string. + Supports both v0.3 and v1.0 CloudEvents: + - v0.3: Uses 'datacontentencoding' attribute with 'data' field + - v1.0: Uses 'data_base64' field (no datacontentencoding) + :param event_factory: A factory function to create CloudEvent instances. + If None, automatically detects version from 'specversion' field. :param data: The JSON formatted byte array. :return: The CloudEvent instance. """ @@ -67,12 +73,33 @@ def read( event_attributes = loads(decoded_data) + # Auto-detect version if factory not provided + if event_factory is None: + from cloudevents.core.bindings.common import get_event_factory_for_version + + specversion = event_attributes.get("specversion", SPECVERSION_V1_0) + event_factory = get_event_factory_for_version(specversion) + if "time" in event_attributes: event_attributes["time"] = isoparse(event_attributes["time"]) + # Handle data field based on version + specversion = event_attributes.get("specversion", SPECVERSION_V1_0) event_data: dict[str, Any] | str | bytes | None = event_attributes.pop( "data", None ) + + # v0.3: Check for datacontentencoding attribute + if ( + specversion == SPECVERSION_V0_3 + and "datacontentencoding" in event_attributes + ): + encoding = event_attributes.get("datacontentencoding", "").lower() + if encoding == "base64" and isinstance(event_data, str): + # Decode base64 encoded data in v0.3 + event_data = base64.b64decode(event_data) + + # v1.0: Check for data_base64 field (when data is None) if event_data is None: event_data_base64 = event_attributes.pop("data_base64", None) if event_data_base64 is not None: @@ -84,15 +111,29 @@ def write(self, event: BaseCloudEvent) -> bytes: """ Write a CloudEvent to a JSON formatted byte string. + Supports both v0.3 and v1.0 CloudEvents: + - v0.3: Uses 'datacontentencoding: base64' with base64-encoded 'data' field + - v1.0: Uses 'data_base64' field (no datacontentencoding) + :param event: The CloudEvent to write. :return: The CloudEvent as a JSON formatted byte array. """ event_data = event.get_data() event_dict: dict[str, Any] = dict(event.get_attributes()) + specversion = event_dict.get("specversion", SPECVERSION_V1_0) if event_data is not None: if isinstance(event_data, (bytes, bytearray)): - event_dict["data_base64"] = base64.b64encode(event_data).decode("utf-8") + # Handle binary data based on version + if specversion == SPECVERSION_V0_3: + # v0.3: Use datacontentencoding with base64-encoded data field + event_dict["datacontentencoding"] = "base64" + event_dict["data"] = base64.b64encode(event_data).decode("utf-8") + else: + # v1.0: Use data_base64 field + event_dict["data_base64"] = base64.b64encode(event_data).decode( + "utf-8" + ) else: datacontenttype = event_dict.get("datacontenttype", "application/json") if re.match(JSONFormat.JSON_CONTENT_TYPE_PATTERN, datacontenttype): diff --git a/src/cloudevents/core/v03/__init__.py b/src/cloudevents/core/v03/__init__.py new file mode 100644 index 00000000..67b5e010 --- /dev/null +++ b/src/cloudevents/core/v03/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""CloudEvents v0.3 implementation module.""" diff --git a/src/cloudevents/core/v03/event.py b/src/cloudevents/core/v03/event.py new file mode 100644 index 00000000..434e24e4 --- /dev/null +++ b/src/cloudevents/core/v03/event.py @@ -0,0 +1,319 @@ +# 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 + +from cloudevents.core import SPECVERSION_V0_3 +from cloudevents.core.base import BaseCloudEvent +from cloudevents.core.exceptions import ( + BaseCloudEventException, + CloudEventValidationError, + CustomExtensionAttributeError, + InvalidAttributeTypeError, + InvalidAttributeValueError, + MissingRequiredAttributeError, +) + +REQUIRED_ATTRIBUTES: Final[list[str]] = ["id", "source", "type", "specversion"] +OPTIONAL_ATTRIBUTES: Final[list[str]] = [ + "datacontenttype", + "datacontentencoding", + "schemaurl", + "subject", + "time", +] + + +class CloudEvent(BaseCloudEvent): + """ + CloudEvents v0.3 implementation. + + This class represents a CloudEvent conforming to the v0.3 specification. + See https://github.com/cloudevents/spec/blob/v0.3/spec.md for details. + """ + + def __init__( + self, + attributes: dict[str, Any], + data: dict[str, Any] | str | bytes | None = None, + ) -> None: + """ + Create a new CloudEvent v0.3 instance. + + :param attributes: The attributes of the CloudEvent instance. + :param data: The payload of the CloudEvent instance. + + :raises CloudEventValidationError: If any of the required attributes are missing or have invalid values. + """ + self._validate_attribute(attributes=attributes) + self._attributes: dict[str, Any] = attributes + self._data: dict[str, Any] | str | bytes | None = data + + @staticmethod + def _validate_attribute(attributes: dict[str, Any]) -> None: + """ + Validates the attributes of the CloudEvent as per the CloudEvents v0.3 specification. + + See https://github.com/cloudevents/spec/blob/v0.3/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: dict[str, list[BaseCloudEventException]] = 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") != SPECVERSION_V0_3: + errors["specversion"].append( + InvalidAttributeValueError( + attribute_name="specversion", + msg=f"Attribute 'specversion' must be '{SPECVERSION_V0_3}'", + ) + ) + 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: dict[str, list[BaseCloudEventException]] = 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 "datacontentencoding" in attributes: + if not isinstance(attributes["datacontentencoding"], str): + errors["datacontentencoding"].append( + InvalidAttributeTypeError( + attribute_name="datacontentencoding", expected_type=str + ) + ) + if not attributes["datacontentencoding"]: + errors["datacontentencoding"].append( + InvalidAttributeValueError( + attribute_name="datacontentencoding", + msg="Attribute 'datacontentencoding' must not be empty", + ) + ) + if "schemaurl" in attributes: + if not isinstance(attributes["schemaurl"], str): + errors["schemaurl"].append( + InvalidAttributeTypeError( + attribute_name="schemaurl", expected_type=str + ) + ) + if not attributes["schemaurl"]: + errors["schemaurl"].append( + InvalidAttributeValueError( + attribute_name="schemaurl", + msg="Attribute 'schemaurl' 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: dict[str, list[BaseCloudEventException]] = 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: + return self._attributes["id"] # type: ignore + + def get_source(self) -> str: + return self._attributes["source"] # type: ignore + + def get_type(self) -> str: + return self._attributes["type"] # type: ignore + + def get_specversion(self) -> str: + return self._attributes["specversion"] # type: ignore + + def get_datacontenttype(self) -> str | None: + return self._attributes.get("datacontenttype") + + def get_dataschema(self) -> str | None: + """ + Get the dataschema attribute. + + Note: In v0.3, this is called 'schemaurl'. This method provides + compatibility with the BaseCloudEvent interface. + """ + return self._attributes.get("schemaurl") + + def get_subject(self) -> str | None: + return self._attributes.get("subject") + + def get_time(self) -> datetime | None: + return self._attributes.get("time") + + def get_extension(self, extension_name: str) -> Any: + return self._attributes.get(extension_name) + + def get_data(self) -> dict[str, Any] | str | bytes | None: + return self._data + + def get_attributes(self) -> dict[str, Any]: + return self._attributes + + # v0.3 specific methods + + def get_datacontentencoding(self) -> str | None: + """ + Get the datacontentencoding attribute (v0.3 only). + + This attribute was removed in v1.0. + """ + return self._attributes.get("datacontentencoding") + + def get_schemaurl(self) -> str | None: + """ + Get the schemaurl attribute (v0.3 only). + + This attribute was renamed to 'dataschema' in v1.0. + """ + return self._attributes.get("schemaurl") diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index a833ec11..16b0a885 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -17,8 +17,9 @@ from datetime import datetime from typing import Any, Final +from cloudevents.core import SPECVERSION_V1_0 from cloudevents.core.base import BaseCloudEvent -from cloudevents.core.v1.exceptions import ( +from cloudevents.core.exceptions import ( BaseCloudEventException, CloudEventValidationError, CustomExtensionAttributeError, @@ -111,11 +112,11 @@ def _validate_required_attributes( attribute_name="specversion", expected_type=str ) ) - if attributes.get("specversion") != "1.0": + if attributes.get("specversion") != SPECVERSION_V1_0: errors["specversion"].append( InvalidAttributeValueError( attribute_name="specversion", - msg="Attribute 'specversion' must be '1.0'", + msg=f"Attribute 'specversion' must be '{SPECVERSION_V1_0}'", ) ) return errors diff --git a/tests/test_core/test_bindings/test_kafka.py b/tests/test_core/test_bindings/test_kafka.py index 8b319c39..3d33a290 100644 --- a/tests/test_core/test_bindings/test_kafka.py +++ b/tests/test_core/test_bindings/test_kafka.py @@ -783,3 +783,35 @@ def test_convenience_with_explicit_format_override() -> None: assert recovered.get_type() == event.get_type() assert recovered.get_data() == event.get_data() + + +def test_from_structured_with_key_auto_detect_v1() -> None: + """Test that auto-detection works when message has key (v1.0)""" + message = KafkaMessage( + headers={"content-type": b"application/cloudevents+json"}, + key=b"partition-key-123", + value=b'{"specversion":"1.0","type":"com.example.test","source":"/test","id":"123"}', + ) + + # Auto-detect version (factory=None) + event = from_structured(message, JSONFormat()) + + assert event.get_type() == "com.example.test" + assert event.get_extension("partitionkey") == "partition-key-123" + assert event.get_attributes()["specversion"] == "1.0" + + +def test_from_structured_with_key_auto_detect_v03() -> None: + """Test that auto-detection works when message has key (v0.3)""" + message = KafkaMessage( + headers={"content-type": b"application/cloudevents+json"}, + key=b"partition-key-456", + value=b'{"specversion":"0.3","type":"com.example.test","source":"/test","id":"456"}', + ) + + # Auto-detect version (factory=None) + event = from_structured(message, JSONFormat()) + + assert event.get_type() == "com.example.test" + assert event.get_extension("partitionkey") == "partition-key-456" + assert event.get_attributes()["specversion"] == "0.3" diff --git a/tests/test_core/test_v03/__init__.py b/tests/test_core/test_v03/__init__.py new file mode 100644 index 00000000..09b419aa --- /dev/null +++ b/tests/test_core/test_v03/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Tests for CloudEvents v0.3 implementation.""" diff --git a/tests/test_core/test_v03/test_event.py b/tests/test_core/test_v03/test_event.py new file mode 100644 index 00000000..aec260bf --- /dev/null +++ b/tests/test_core/test_v03/test_event.py @@ -0,0 +1,438 @@ +# 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.exceptions import ( + CloudEventValidationError, + CustomExtensionAttributeError, + InvalidAttributeTypeError, + InvalidAttributeValueError, + MissingRequiredAttributeError, +) +from cloudevents.core.v03.event import CloudEvent + + +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 '0.3'" + ) + ), + ], + } + + actual_errors = { + key: [str(e) for e in value] for key, value in e.value.errors.items() + } + assert actual_errors == expected_errors + + +def test_invalid_specversion() -> None: + """Test that v0.3 CloudEvent rejects non-0.3 specversion""" + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "1.0", # Wrong version! + } + ) + + assert "specversion" in e.value.errors + assert any("must be '0.3'" in str(err) for err in e.value.errors["specversion"]) + + +@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": "0.3", + "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": "0.3", + "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": "0.3", + "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( + "datacontentencoding,expected_error", + [ + ( + 1234, + { + "datacontentencoding": [ + str(InvalidAttributeTypeError("datacontentencoding", str)) + ] + }, + ), + ( + "", + { + "datacontentencoding": [ + str( + InvalidAttributeValueError( + "datacontentencoding", + "Attribute 'datacontentencoding' must not be empty", + ) + ) + ] + }, + ), + ], +) +def test_datacontentencoding_validation( + datacontentencoding: Any, expected_error: dict +) -> None: + """Test v0.3 specific datacontentencoding attribute validation""" + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "0.3", + "datacontentencoding": datacontentencoding, + } + ) + + 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( + "schemaurl,expected_error", + [ + ( + 1234, + {"schemaurl": [str(InvalidAttributeTypeError("schemaurl", str))]}, + ), + ( + "", + { + "schemaurl": [ + str( + InvalidAttributeValueError( + "schemaurl", "Attribute 'schemaurl' must not be empty" + ) + ) + ] + }, + ), + ], +) +def test_schemaurl_validation(schemaurl: Any, expected_error: dict) -> None: + """Test v0.3 specific schemaurl attribute validation""" + with pytest.raises(CloudEventValidationError) as e: + CloudEvent( + { + "id": "1", + "source": "/", + "type": "test", + "specversion": "0.3", + "schemaurl": schemaurl, + } + ) + + 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": "0.3", + 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_v03_constructor() -> None: + """Test creating a v0.3 CloudEvent with all attributes""" + id = "1" + source = "/source" + type = "com.test.type" + specversion = "0.3" + datacontenttype = "application/json" + datacontentencoding = "base64" + schemaurl = "http://example.com/schema.json" + 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, + "datacontentencoding": datacontentencoding, + "schemaurl": schemaurl, + "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_datacontentencoding() == datacontentencoding + assert event.get_schemaurl() == schemaurl + assert event.get_subject() == subject + assert event.get_time() == time + assert event.get_extension("customextension") == customextension + assert event.get_data() == data + + +def test_get_dataschema_returns_schemaurl() -> None: + """Test that get_dataschema() returns schemaurl for v0.3 compatibility""" + event = CloudEvent( + attributes={ + "id": "1", + "source": "/source", + "type": "com.test.type", + "specversion": "0.3", + "schemaurl": "http://example.com/schema.json", + } + ) + + # get_dataschema should return the schemaurl value for compatibility + assert event.get_dataschema() == "http://example.com/schema.json" + assert event.get_schemaurl() == "http://example.com/schema.json" + + +def test_v03_minimal_event() -> None: + """Test creating a minimal v0.3 CloudEvent""" + event = CloudEvent( + attributes={ + "id": "test-123", + "source": "https://example.com/source", + "type": "com.example.test", + "specversion": "0.3", + } + ) + + assert event.get_id() == "test-123" + assert event.get_source() == "https://example.com/source" + assert event.get_type() == "com.example.test" + assert event.get_specversion() == "0.3" + assert event.get_data() is None + assert event.get_datacontentencoding() is None + assert event.get_schemaurl() is None diff --git a/tests/test_core/test_v03/test_http_bindings.py b/tests/test_core/test_v03/test_http_bindings.py new file mode 100644 index 00000000..13bcd592 --- /dev/null +++ b/tests/test_core/test_v03/test_http_bindings.py @@ -0,0 +1,511 @@ +# 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 cloudevents.core.bindings.http import ( + HTTPMessage, + from_binary, + from_binary_event, + from_http, + from_http_event, + from_structured, + from_structured_event, + to_binary, + to_structured, +) +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.v03.event import CloudEvent + + +def test_v03_to_binary_minimal() -> None: + """Test converting minimal v0.3 event to HTTP binary mode""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + } + ) + + message = to_binary(event, JSONFormat()) + + assert "ce-specversion" in message.headers + assert message.headers["ce-specversion"] == "0.3" + assert "ce-type" in message.headers + assert "ce-source" in message.headers + assert "ce-id" in message.headers + + +def test_v03_to_binary_with_schemaurl() -> None: + """Test converting v0.3 event with schemaurl to HTTP binary mode""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "schemaurl": "https://example.com/schema.json", + } + ) + + message = to_binary(event, JSONFormat()) + + assert "ce-schemaurl" in message.headers + # URL should be percent-encoded + assert "https" in message.headers["ce-schemaurl"] + + +def test_v03_to_binary_with_datacontentencoding() -> None: + """Test converting v0.3 event with datacontentencoding to HTTP binary mode""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "datacontentencoding": "base64", + } + ) + + message = to_binary(event, JSONFormat()) + + assert "ce-datacontentencoding" in message.headers + assert message.headers["ce-datacontentencoding"] == "base64" + + +def test_v03_from_binary_minimal() -> None: + """Test parsing minimal v0.3 binary HTTP message""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "test-123" + + +def test_v03_from_binary_with_schemaurl() -> None: + """Test parsing v0.3 binary HTTP message with schemaurl""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-schemaurl": "https://example.com/schema.json", + }, + body=b"", + ) + + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_schemaurl() == "https://example.com/schema.json" + + +def test_v03_from_binary_with_datacontentencoding() -> None: + """Test parsing v0.3 binary HTTP message with datacontentencoding""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + "ce-datacontentencoding": "base64", + }, + body=b"", + ) + + event = from_binary(message, JSONFormat(), CloudEvent) + + assert event.get_datacontentencoding() == "base64" + + +def test_v03_binary_round_trip() -> None: + """Test v0.3 binary mode round-trip""" + original = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "subject": "test-subject", + "schemaurl": "https://example.com/schema.json", + "datacontenttype": "application/json", + }, + data={"message": "Hello", "count": 42}, + ) + + # Convert to binary + message = to_binary(original, JSONFormat()) + + # Parse back + parsed = from_binary(message, JSONFormat(), CloudEvent) + + assert parsed.get_specversion() == original.get_specversion() + assert parsed.get_type() == original.get_type() + assert parsed.get_source() == original.get_source() + assert parsed.get_id() == original.get_id() + assert parsed.get_subject() == original.get_subject() + assert parsed.get_schemaurl() == original.get_schemaurl() + assert parsed.get_datacontenttype() == original.get_datacontenttype() + assert parsed.get_data() == original.get_data() + + +def test_v03_to_structured_minimal() -> None: + """Test converting minimal v0.3 event to HTTP structured mode""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + } + ) + + message = to_structured(event, JSONFormat()) + + assert message.headers["content-type"] == "application/cloudevents+json" + assert b'"specversion": "0.3"' in message.body + assert b'"type": "com.example.test"' in message.body + + +def test_v03_to_structured_with_schemaurl() -> None: + """Test converting v0.3 event with schemaurl to structured mode""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "schemaurl": "https://example.com/schema.json", + } + ) + + message = to_structured(event, JSONFormat()) + + assert b'"schemaurl": "https://example.com/schema.json"' in message.body + + +def test_v03_from_structured_minimal() -> None: + """Test parsing minimal v0.3 structured HTTP message""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + event = from_structured(message, JSONFormat(), CloudEvent) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "test-123" + + +def test_v03_from_structured_with_schemaurl() -> None: + """Test parsing v0.3 structured HTTP message with schemaurl""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123", "schemaurl": "https://example.com/schema.json"}', + ) + + event = from_structured(message, JSONFormat(), CloudEvent) + + assert event.get_schemaurl() == "https://example.com/schema.json" + + +def test_v03_structured_round_trip() -> None: + """Test v0.3 structured mode round-trip""" + original = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "subject": "test-subject", + "schemaurl": "https://example.com/schema.json", + "datacontenttype": "application/json", + }, + data={"message": "Hello", "count": 42}, + ) + + # Convert to structured + message = to_structured(original, JSONFormat()) + + # Parse back + parsed = from_structured(message, JSONFormat(), CloudEvent) + + assert parsed.get_specversion() == original.get_specversion() + assert parsed.get_type() == original.get_type() + assert parsed.get_source() == original.get_source() + assert parsed.get_id() == original.get_id() + assert parsed.get_subject() == original.get_subject() + assert parsed.get_schemaurl() == original.get_schemaurl() + assert parsed.get_datacontenttype() == original.get_datacontenttype() + assert parsed.get_data() == original.get_data() + + +def test_v03_from_http_auto_detects_binary() -> None: + """Test that from_http auto-detects v0.3 binary mode""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + event = from_http(message, JSONFormat(), CloudEvent) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + + +def test_v03_from_http_auto_detects_structured() -> None: + """Test that from_http auto-detects v0.3 structured mode""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + event = from_http(message, JSONFormat(), CloudEvent) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + + +def test_v03_auto_detect_version_from_binary_headers() -> None: + """Test auto-detection of v0.3 from binary mode headers""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + # Don't provide event_factory, let it auto-detect + event = from_binary(message, JSONFormat()) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_auto_detect_version_from_structured_body() -> None: + """Test auto-detection of v0.3 from structured mode body""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + # Don't provide event_factory, let it auto-detect + event = from_structured(message, JSONFormat()) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_from_http_auto_detect_version_binary() -> None: + """Test from_http auto-detects v0.3 with no explicit factory""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + # Auto-detect both mode and version + event = from_http(message, JSONFormat()) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_from_http_auto_detect_version_structured() -> None: + """Test from_http auto-detects v0.3 structured with no explicit factory""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + # Auto-detect both mode and version + event = from_http(message, JSONFormat()) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_convenience_wrappers_binary() -> None: + """Test convenience wrapper functions with v0.3 binary mode""" + message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + event = from_binary_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_convenience_wrappers_structured() -> None: + """Test convenience wrapper functions with v0.3 structured mode""" + message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + event = from_structured_event(message) + + assert isinstance(event, CloudEvent) + assert event.get_specversion() == "0.3" + + +def test_v03_convenience_wrappers_from_http() -> None: + """Test from_http_event convenience wrapper with v0.3""" + # Binary mode + binary_message = HTTPMessage( + headers={ + "ce-specversion": "0.3", + "ce-type": "com.example.test", + "ce-source": "/test", + "ce-id": "test-123", + }, + body=b"", + ) + + event1 = from_http_event(binary_message) + assert event1.get_specversion() == "0.3" + + # Structured mode + structured_message = HTTPMessage( + headers={"content-type": "application/cloudevents+json"}, + body=b'{"specversion": "0.3", "type": "com.example.test", "source": "/test", "id": "test-123"}', + ) + + event2 = from_http_event(structured_message) + assert event2.get_specversion() == "0.3" + + +def test_v03_binary_with_time() -> None: + """Test v0.3 binary mode with time attribute""" + dt = datetime(2023, 6, 15, 14, 30, 45, tzinfo=timezone.utc) + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": dt, + } + ) + + message = to_binary(event, JSONFormat()) + parsed = from_binary(message, JSONFormat(), CloudEvent) + + assert parsed.get_time() is not None + assert parsed.get_time().year == 2023 + + +def test_v03_complete_binary_event() -> None: + """Test v0.3 complete event with all attributes in binary mode""" + dt = datetime(2023, 6, 15, 14, 30, 45, tzinfo=timezone.utc) + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": dt, + "subject": "test-subject", + "datacontenttype": "application/json", + "datacontentencoding": "base64", + "schemaurl": "https://example.com/schema.json", + "customext": "custom-value", + }, + data={"message": "Hello World!"}, + ) + + message = to_binary(event, JSONFormat()) + parsed = from_binary(message, JSONFormat()) # Auto-detect + + assert isinstance(parsed, CloudEvent) + assert parsed.get_specversion() == "0.3" + assert parsed.get_type() == "com.example.test" + assert parsed.get_source() == "/test" + assert parsed.get_id() == "test-123" + assert parsed.get_subject() == "test-subject" + assert parsed.get_datacontenttype() == "application/json" + assert parsed.get_datacontentencoding() == "base64" + assert parsed.get_schemaurl() == "https://example.com/schema.json" + assert parsed.get_extension("customext") == "custom-value" + assert parsed.get_data() == {"message": "Hello World!"} + + +def test_v03_complete_structured_event() -> None: + """Test v0.3 complete event with all attributes in structured mode""" + dt = datetime(2023, 6, 15, 14, 30, 45, tzinfo=timezone.utc) + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": dt, + "subject": "test-subject", + "datacontenttype": "application/json", + "schemaurl": "https://example.com/schema.json", + "customext": "custom-value", + }, + data={"message": "Hello World!"}, + ) + + message = to_structured(event, JSONFormat()) + parsed = from_structured(message, JSONFormat()) # Auto-detect + + assert isinstance(parsed, CloudEvent) + assert parsed.get_specversion() == "0.3" + assert parsed.get_type() == "com.example.test" + assert parsed.get_source() == "/test" + assert parsed.get_id() == "test-123" + assert parsed.get_subject() == "test-subject" + assert parsed.get_datacontenttype() == "application/json" + assert parsed.get_schemaurl() == "https://example.com/schema.json" + assert parsed.get_extension("customext") == "custom-value" + assert parsed.get_data() == {"message": "Hello World!"} diff --git a/tests/test_core/test_v03/test_json_format.py b/tests/test_core/test_v03/test_json_format.py new file mode 100644 index 00000000..f863500a --- /dev/null +++ b/tests/test_core/test_v03/test_json_format.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 base64 +from datetime import datetime, timezone + +from cloudevents.core.formats.json import JSONFormat +from cloudevents.core.v03.event import CloudEvent + + +def test_v03_json_read_minimal() -> None: + """Test reading a minimal v0.3 CloudEvent from JSON""" + json_data = b"""{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123" + }""" + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "test-123" + assert event.get_data() is None + + +def test_v03_json_write_minimal() -> None: + """Test writing a minimal v0.3 CloudEvent to JSON""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + } + ) + + format = JSONFormat() + json_bytes = format.write(event) + json_str = json_bytes.decode("utf-8") + + assert '"specversion": "0.3"' in json_str + assert '"type": "com.example.test"' in json_str + assert '"source": "/test"' in json_str + assert '"id": "test-123"' in json_str + + +def test_v03_json_with_schemaurl() -> None: + """Test v0.3 schemaurl attribute in JSON""" + json_data = b"""{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "schemaurl": "https://example.com/schema.json" + }""" + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + assert event.get_schemaurl() == "https://example.com/schema.json" + assert event.get_dataschema() == "https://example.com/schema.json" + + +def test_v03_json_write_with_schemaurl() -> None: + """Test writing v0.3 event with schemaurl to JSON""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "schemaurl": "https://example.com/schema.json", + } + ) + + format = JSONFormat() + json_bytes = format.write(event) + json_str = json_bytes.decode("utf-8") + + assert '"schemaurl": "https://example.com/schema.json"' in json_str + + +def test_v03_json_with_datacontentencoding_base64() -> None: + """Test v0.3 datacontentencoding with base64 encoded data""" + # In v0.3, when datacontentencoding is base64, the data field contains base64 string + original_data = b"Hello World!" + base64_data = base64.b64encode(original_data).decode("utf-8") + + json_data = f'''{{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "datacontentencoding": "base64", + "data": "{base64_data}" + }}'''.encode("utf-8") + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + assert event.get_datacontentencoding() == "base64" + assert event.get_data() == original_data # Should be decoded + + +def test_v03_json_write_binary_data_with_base64() -> None: + """Test writing v0.3 event with binary data (uses datacontentencoding)""" + binary_data = b"Hello World!" + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + }, + data=binary_data, + ) + + format = JSONFormat() + json_bytes = format.write(event) + json_str = json_bytes.decode("utf-8") + + # v0.3 should use datacontentencoding with base64-encoded data field + assert '"datacontentencoding": "base64"' in json_str + assert '"data"' in json_str + assert '"data_base64"' not in json_str # v1.0 field should not be present + + # Verify we can read it back + event_read = format.read(CloudEvent, json_bytes) + assert event_read.get_data() == binary_data + + +def test_v03_json_round_trip_with_binary_data() -> None: + """Test complete round-trip of v0.3 event with binary data""" + original_data = b"\x00\x01\x02\x03\x04\x05" + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "datacontenttype": "application/octet-stream", + }, + data=original_data, + ) + + format = JSONFormat() + + # Write to JSON + json_bytes = format.write(event) + + # Read back + event_read = format.read(CloudEvent, json_bytes) + + assert event_read.get_specversion() == "0.3" + assert event_read.get_data() == original_data + assert event_read.get_datacontentencoding() == "base64" + + +def test_v03_json_with_dict_data() -> None: + """Test v0.3 event with JSON dict data""" + json_data = b"""{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "datacontenttype": "application/json", + "data": {"message": "Hello", "count": 42} + }""" + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + data = event.get_data() + assert isinstance(data, dict) + assert data["message"] == "Hello" + assert data["count"] == 42 + + +def test_v03_json_write_with_dict_data() -> None: + """Test writing v0.3 event with dict data""" + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "datacontenttype": "application/json", + }, + data={"message": "Hello", "count": 42}, + ) + + format = JSONFormat() + json_bytes = format.write(event) + json_str = json_bytes.decode("utf-8") + + assert ( + '"data": {"message": "Hello", "count": 42}' in json_str + or '"data": {"count": 42, "message": "Hello"}' in json_str + ) + + +def test_v03_json_with_time() -> None: + """Test v0.3 event with time attribute""" + json_data = b"""{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": "2023-06-15T14:30:45Z" + }""" + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + time = event.get_time() + assert isinstance(time, datetime) + assert time.year == 2023 + assert time.month == 6 + assert time.day == 15 + + +def test_v03_json_write_with_time() -> None: + """Test writing v0.3 event with time""" + dt = datetime(2023, 6, 15, 14, 30, 45, tzinfo=timezone.utc) + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": dt, + } + ) + + format = JSONFormat() + json_bytes = format.write(event) + json_str = json_bytes.decode("utf-8") + + assert '"time": "2023-06-15T14:30:45Z"' in json_str + + +def test_v03_json_complete_event() -> None: + """Test v0.3 event with all optional attributes""" + json_data = b"""{ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": "2023-06-15T14:30:45Z", + "subject": "test-subject", + "datacontenttype": "application/json", + "schemaurl": "https://example.com/schema.json", + "customext": "custom-value", + "data": {"message": "Hello"} + }""" + + format = JSONFormat() + event = format.read(CloudEvent, json_data) + + assert event.get_specversion() == "0.3" + assert event.get_type() == "com.example.test" + assert event.get_source() == "/test" + assert event.get_id() == "test-123" + assert event.get_subject() == "test-subject" + assert event.get_datacontenttype() == "application/json" + assert event.get_schemaurl() == "https://example.com/schema.json" + assert event.get_extension("customext") == "custom-value" + assert event.get_data() == {"message": "Hello"} + + +def test_v03_json_round_trip_complete() -> None: + """Test complete round-trip of v0.3 event with all attributes""" + dt = datetime(2023, 6, 15, 14, 30, 45, tzinfo=timezone.utc) + + event = CloudEvent( + attributes={ + "specversion": "0.3", + "type": "com.example.test", + "source": "/test", + "id": "test-123", + "time": dt, + "subject": "test-subject", + "datacontenttype": "application/json", + "schemaurl": "https://example.com/schema.json", + "customext": "custom-value", + }, + data={"message": "Hello", "count": 42}, + ) + + format = JSONFormat() + + # Write to JSON + json_bytes = format.write(event) + + # Read back + event_read = format.read(CloudEvent, json_bytes) + + assert event_read.get_specversion() == event.get_specversion() + assert event_read.get_type() == event.get_type() + assert event_read.get_source() == event.get_source() + assert event_read.get_id() == event.get_id() + assert event_read.get_subject() == event.get_subject() + assert event_read.get_datacontenttype() == event.get_datacontenttype() + assert event_read.get_schemaurl() == event.get_schemaurl() + assert event_read.get_extension("customext") == event.get_extension("customext") + assert event_read.get_data() == event.get_data() diff --git a/tests/test_core/test_v1/test_event.py b/tests/test_core/test_v1/test_event.py index acd3fd2b..167db109 100644 --- a/tests/test_core/test_v1/test_event.py +++ b/tests/test_core/test_v1/test_event.py @@ -17,14 +17,14 @@ import pytest -from cloudevents.core.v1.event import CloudEvent -from cloudevents.core.v1.exceptions import ( +from cloudevents.core.exceptions import ( CloudEventValidationError, CustomExtensionAttributeError, InvalidAttributeTypeError, InvalidAttributeValueError, MissingRequiredAttributeError, ) +from cloudevents.core.v1.event import CloudEvent def test_missing_required_attributes() -> None: