diff --git a/pyproject.toml b/pyproject.toml index 5c152bc0..1f72d939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ keywords = [ ] dependencies = [ "ruff>=0.6.8", + "python-dateutil>=2.8.2", ] [project.urls] @@ -53,6 +54,7 @@ dev-dependencies = [ "flake8-print>=5.0.0", "pre-commit>=3.8.0", "pytest-cov>=5.0.0", + "types-python-dateutil>=2.9.0.20241003", ] [tool.uv.pip] diff --git a/src/cloudevents/core/base.py b/src/cloudevents/core/base.py new file mode 100644 index 00000000..ee887407 --- /dev/null +++ b/src/cloudevents/core/base.py @@ -0,0 +1,130 @@ +# 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 +from typing import Any, Optional, Protocol, Union + + +class BaseCloudEvent(Protocol): + """ + 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[Union[dict, str, bytes]] = 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. + """ + ... + + def get_id(self) -> str: + """ + Retrieve the ID of the event. + + :return: The ID of the event. + """ + ... + + def get_source(self) -> str: + """ + Retrieve the source of the event. + + :return: The source of the event. + """ + ... + + def get_type(self) -> str: + """ + Retrieve the type of the event. + + :return: The type of the event. + """ + ... + + def get_specversion(self) -> str: + """ + Retrieve the specversion of the event. + + :return: The specversion of the event. + """ + ... + + def get_datacontenttype(self) -> Optional[str]: + """ + Retrieve the datacontenttype of the event. + + :return: The datacontenttype of the event. + """ + ... + + def get_dataschema(self) -> Optional[str]: + """ + Retrieve the dataschema of the event. + + :return: The dataschema of the event. + """ + ... + + def get_subject(self) -> Optional[str]: + """ + Retrieve the subject of the event. + + :return: The subject of the event. + """ + ... + + def get_time(self) -> Optional[datetime]: + """ + Retrieve the time of the event. + + :return: The time of the event. + """ + ... + + 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. + """ + ... + + def get_data(self) -> Optional[Union[dict, str, bytes]]: + """ + Retrieve data of the event. + + :return: The data of the event. + """ + ... + + def get_attributes(self) -> dict[str, Any]: + """ + Retrieve all attributes of the event. + + :return: The attributes of the event. + """ + ... diff --git a/src/cloudevents/core/formats/__init__.py b/src/cloudevents/core/formats/__init__.py new file mode 100644 index 00000000..8043675e --- /dev/null +++ b/src/cloudevents/core/formats/__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/src/cloudevents/core/formats/base.py b/src/cloudevents/core/formats/base.py new file mode 100644 index 00000000..78598007 --- /dev/null +++ b/src/cloudevents/core/formats/base.py @@ -0,0 +1,58 @@ +# Copyright 2018-Present The CloudEvents Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from typing import Callable, Optional, Protocol, Union + +from cloudevents.core.base import BaseCloudEvent + + +class Format(Protocol): + """ + Protocol defining the contract for CloudEvent format implementations. + + Format implementations are responsible for serializing and deserializing CloudEvents + to and from specific wire formats (e.g., JSON, Avro, Protobuf). Each format must + implement both read and write operations to convert between CloudEvent objects and + their byte representations according to the CloudEvents specification. + """ + + def read( + self, + event_factory: Callable[ + [dict, Optional[Union[dict, str, bytes]]], BaseCloudEvent + ], + data: Union[str, bytes], + ) -> BaseCloudEvent: + """ + Deserialize a CloudEvent from its wire format representation. + + :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). + :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. + """ + ... + + def write(self, event: BaseCloudEvent) -> bytes: + """ + Serialize a CloudEvent to its wire format representation. + + :param event: The CloudEvent instance to serialize. + :return: The CloudEvent serialized as bytes in the format's wire representation. + :raises ValueError: If the event cannot be serialized according to the format. + """ + ... diff --git a/src/cloudevents/core/formats/json.py b/src/cloudevents/core/formats/json.py new file mode 100644 index 00000000..f674be09 --- /dev/null +++ b/src/cloudevents/core/formats/json.py @@ -0,0 +1,104 @@ +# 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 +import re +from datetime import datetime +from json import JSONEncoder, dumps, loads +from typing import Any, Callable, Final, Optional, Pattern, Union + +from dateutil.parser import isoparse # type: ignore[import-untyped] + +from cloudevents.core.base import BaseCloudEvent +from cloudevents.core.formats.base import Format + + +class _JSONEncoderWithDatetime(JSONEncoder): + """ + Custom JSON encoder to handle datetime objects in the format required by the CloudEvents spec. + """ + + def default(self, obj: Any) -> Any: + if isinstance(obj, datetime): + dt = obj.isoformat() + # 'Z' denotes a UTC offset of 00:00 see + # https://www.rfc-editor.org/rfc/rfc3339#section-2 + if dt.endswith("+00:00"): + dt = dt.removesuffix("+00:00") + "Z" + return dt + + return super().default(obj) + + +class JSONFormat(Format): + CONTENT_TYPE: Final[str] = "application/cloudevents+json" + JSON_CONTENT_TYPE_PATTERN: Pattern[str] = re.compile( + r"^(application|text)/([a-zA-Z0-9\-\.]+\+)?json(;.*)?$" + ) + + def read( + self, + event_factory: Callable[ + [dict, Optional[Union[dict, str, bytes]]], BaseCloudEvent + ], + data: Union[str, bytes], + ) -> BaseCloudEvent: + """ + Read a CloudEvent from a JSON formatted byte string. + + :param event_factory: A factory function to create CloudEvent instances. + :param data: The JSON formatted byte array. + :return: The CloudEvent instance. + """ + decoded_data: str + if isinstance(data, bytes): + decoded_data = data.decode("utf-8") + else: + decoded_data = data + + event_attributes = loads(decoded_data) + + if "time" in event_attributes: + event_attributes["time"] = isoparse(event_attributes["time"]) + + event_data: Union[dict, str, bytes, None] = event_attributes.pop("data", None) + if event_data is None: + event_data_base64 = event_attributes.pop("data_base64", None) + if event_data_base64 is not None: + event_data = base64.b64decode(event_data_base64) + + return event_factory(event_attributes, event_data) + + def write(self, event: BaseCloudEvent) -> bytes: + """ + Write a CloudEvent to a JSON formatted byte string. + + :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()) + + if event_data is not None: + if isinstance(event_data, (bytes, bytearray)): + 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): + event_dict["data"] = event_data + else: + event_dict["data"] = str(event_data) + + return dumps(event_dict, cls=_JSONEncoderWithDatetime).encode("utf-8") diff --git a/src/cloudevents/core/v1/event.py b/src/cloudevents/core/v1/event.py index 043670b5..71e6ef01 100644 --- a/src/cloudevents/core/v1/event.py +++ b/src/cloudevents/core/v1/event.py @@ -15,8 +15,9 @@ import re from collections import defaultdict from datetime import datetime -from typing import Any, Final, Optional +from typing import Any, Final, Optional, Union +from cloudevents.core.base import BaseCloudEvent from cloudevents.core.v1.exceptions import ( BaseCloudEventException, CloudEventValidationError, @@ -35,28 +36,13 @@ ] -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. - """ +class CloudEvent(BaseCloudEvent): + def __init__( + self, attributes: dict[str, Any], data: Optional[Union[dict, str, bytes]] = None + ) -> None: self._validate_attribute(attributes=attributes) self._attributes: dict[str, Any] = attributes - self._data: Optional[dict] = data + self._data: Optional[Union[dict, str, bytes]] = data @staticmethod def _validate_attribute(attributes: dict[str, Any]) -> None: @@ -243,82 +229,34 @@ def _validate_extension_attributes( 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. - """ + def get_data(self) -> Optional[Union[dict, str, bytes]]: return self._data + + def get_attributes(self) -> dict[str, Any]: + return self._attributes diff --git a/tests/test_core/test_format/__init__.py b/tests/test_core/test_format/__init__.py new file mode 100644 index 00000000..8043675e --- /dev/null +++ b/tests/test_core/test_format/__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_format/test_json.py b/tests/test_core/test_format/test_json.py new file mode 100644 index 00000000..12f75435 --- /dev/null +++ b/tests/test_core/test_format/test_json.py @@ -0,0 +1,325 @@ +# 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.formats.json import JSONFormat +from cloudevents.core.v1.event import CloudEvent + + +def test_write_cloud_event_to_json_with_attributes_only() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "application/json", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data=None) + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject"}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_data_as_json() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "application/json", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data={"key": "value"}) + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": {"key": "value"}}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_data_as_bytes() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "application/json", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data=b"test") + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data_base64": "dGVzdA=="}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_data_as_str_and_content_type_not_json() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "datacontenttype": "text/plain", + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data="test") + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "text/plain", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "test"}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_no_content_type_set_and_data_as_str() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data="I'm just a string") + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": "I\'m just a string"}'.encode( + "utf-8" + ) + ) + + +def test_write_cloud_event_to_json_with_no_content_type_set_and_data_as_json() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + "dataschema": "http://example.com/schema", + "subject": "test_subject", + } + event = CloudEvent(attributes=attributes, data={"key": "value"}) + formatter = JSONFormat() + result = formatter.write(event) + + assert ( + result + == '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": {"key": "value"}}'.encode( + "utf-8" + ) + ) + + +def test_read_cloud_event_from_json_with_attributes_only() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject"}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_id() == "123" + assert result.get_source() == "source" + assert result.get_type() == "type" + assert result.get_specversion() == "1.0" + assert result.get_time() == datetime( + 2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc + ) + assert result.get_datacontenttype() == "application/json" + assert result.get_dataschema() == "http://example.com/schema" + assert result.get_subject() == "test_subject" + assert result.get_data() is None + + +def test_read_cloud_event_from_json_with_bytes_as_data() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data_base64": "dGVzdA=="}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_id() == "123" + assert result.get_source() == "source" + assert result.get_type() == "type" + assert result.get_specversion() == "1.0" + assert result.get_time() == datetime( + 2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc + ) + assert result.get_datacontenttype() == "application/json" + assert result.get_dataschema() == "http://example.com/schema" + assert result.get_subject() == "test_subject" + assert result.get_data() == b"test" + + +def test_read_cloud_event_from_json_with_json_as_data() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "time": "2023-10-25T17:09:19.736166Z", "datacontenttype": "application/json", "dataschema": "http://example.com/schema", "subject": "test_subject", "data": {"key": "value"}}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_id() == "123" + assert result.get_source() == "source" + assert result.get_type() == "type" + assert result.get_specversion() == "1.0" + assert result.get_time() == datetime( + 2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc + ) + assert result.get_datacontenttype() == "application/json" + assert result.get_dataschema() == "http://example.com/schema" + assert result.get_subject() == "test_subject" + assert result.get_data() == {"key": "value"} + + +def test_write_cloud_event_with_extension_attributes() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "customext1": "value1", + "customext2": 123, + } + event = CloudEvent(attributes=attributes, data=None) + formatter = JSONFormat() + result = formatter.write(event) + + assert b'"customext1": "value1"' in result + assert b'"customext2": 123' in result + + +def test_read_cloud_event_with_extension_attributes() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "customext1": "value1", "customext2": 123}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_extension("customext1") == "value1" + assert result.get_extension("customext2") == 123 + + +def test_write_cloud_event_with_different_json_content_types() -> None: + test_cases = [ + ("application/vnd.api+json", {"key": "value"}), + ("text/json", {"key": "value"}), + ("application/json; charset=utf-8", {"key": "value"}), + ] + + for content_type, data in test_cases: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "datacontenttype": content_type, + } + event = CloudEvent(attributes=attributes, data=data) + formatter = JSONFormat() + result = formatter.write(event) + + assert b'"data": {"key": "value"}' in result + + +def test_read_cloud_event_with_string_data() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "data": "plain string data"}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_data() == "plain string data" + + +def test_write_cloud_event_with_utc_timezone_z_suffix() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + "time": datetime(2023, 10, 25, 17, 9, 19, 736166, tzinfo=timezone.utc), + } + event = CloudEvent(attributes=attributes, data=None) + formatter = JSONFormat() + result = formatter.write(event) + + assert b'"time": "2023-10-25T17:09:19.736166Z"' in result + + +def test_write_cloud_event_with_unicode_data() -> None: + attributes = { + "id": "123", + "source": "source", + "type": "type", + "specversion": "1.0", + } + event = CloudEvent(attributes=attributes, data="Hello δΈ–η•Œ 🌍") + formatter = JSONFormat() + result = formatter.write(event) + + decoded = result.decode("utf-8") + assert '"data": "Hello' in decoded + assert "Hello" in decoded + + +def test_read_cloud_event_with_unicode_data() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0", "data": "Hello δΈ–η•Œ 🌍"}'.encode( + "utf-8" + ) + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_data() == "Hello δΈ–η•Œ 🌍" + + +def test_read_cloud_event_from_string_input() -> None: + data = '{"id": "123", "source": "source", "type": "type", "specversion": "1.0"}' + formatter = JSONFormat() + result = formatter.read(CloudEvent, data) + + assert result.get_id() == "123" + assert result.get_source() == "source" diff --git a/uv.lock b/uv.lock index ae08d830..4a6a6605 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,7 @@ name = "cloudevents" version = "2.0.0a1" source = { editable = "." } dependencies = [ + { name = "python-dateutil" }, { name = "ruff" }, ] @@ -28,10 +29,14 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "types-python-dateutil" }, ] [package.metadata] -requires-dist = [{ name = "ruff", specifier = ">=0.6.8" }] +requires-dist = [ + { name = "python-dateutil", specifier = ">=2.8.2" }, + { name = "ruff", specifier = ">=0.6.8" }, +] [package.metadata.requires-dev] dev = [ @@ -43,6 +48,7 @@ dev = [ { name = "pre-commit", specifier = ">=3.8.0" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "types-python-dateutil", specifier = ">=2.9.0.20241003" }, ] [[package]] @@ -373,6 +379,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -451,6 +469,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/bd/a8b0c64945a92eaeeb8d0283f27a726a776a1c9d12734d990c5fc7a1278c/ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc", size = 8669595 }, ] +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, +] + [[package]] name = "tomli" version = "2.0.1" @@ -460,6 +487,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, ] +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20241003" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f8/f6ee4c803a7beccffee21bb29a71573b39f7037c224843eff53e5308c16e/types-python-dateutil-2.9.0.20241003.tar.gz", hash = "sha256:58cb85449b2a56d6684e41aeefb4c4280631246a0da1a719bdbe6f3fb0317446", size = 9210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/d6/ba5f61958f358028f2e2ba1b8e225b8e263053bd57d3a79e2d2db64c807b/types_python_dateutil-2.9.0.20241003-py3-none-any.whl", hash = "sha256:250e1d8e80e7bbc3a6c99b907762711d1a1cdd00e978ad39cb5940f6f0a87f3d", size = 9693 }, +] + [[package]] name = "typing-extensions" version = "4.12.2"