Skip to content

Commit 785bfe7

Browse files
sasha-tkachevpre-commit-ci[bot]xSAVIKx
authored
refactor: create abstract cloudevent (cloudevents#186)
* fix: non-cloudevents values must not equal to cloudevents values (cloudevents#171) Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * test: refactor move fixtures to beginning Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * test: cloudevent equality bug regression (cloudevents#171) Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * style: remove redundent else Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * test: remove redundent test Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * test: refactor non_cloudevent_value into a parameterization Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * docs: update changelog Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * docs: fix bad merge Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: abstract event Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * feat: add missing return type Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * feat: create function Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * feat: any cloud event Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: move to abstract Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: integrate abstract event Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: create abstract cloudevent package Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * docs: abstract cloudevent Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * feat: simplify data attributes Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * fix: intengrate data read model Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * feat: define abstract methods Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: use anycloudevent for generics Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * docs: getitem documentation Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * docs: better cloudevent explenation Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * docs: explain read model Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * docs: not implemented errors Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * docs: explain why impl has no public attributes property Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * docs: add missing comment to from_http Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * test: add abstract cloudevent coverage tests Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: rename abstract to generic Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: cloudevent is no longer absctract Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * test: fix broken test Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * Revert "refactor: rename abstract to generic" This reverts commit 89d30eb. Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: move all abstract conversion logic under conversion Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * test: rename badly named test Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: add default value for conversions Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * docs: remove inconsistent types Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: remove mutation variables from contract Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: expose data and attributes in class Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * test: remove broken tests Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: use classmethods Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: remove optional type Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * refactor: convert get_data and get_attributes to private member functions instead of classmethods Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * build: ignore not-implemented functions in coverage Signed-off-by: Alexander Tkachev <sasha64sasha@gmail.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * docs: mentioned default branch change in the changelog Signed-off-by: Yurii Serhiichuk <savik.ne@gmail.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Yurii Serhiichuk <savik.ne@gmail.com>
1 parent 61c8657 commit 785bfe7

File tree

7 files changed

+411
-192
lines changed

7 files changed

+411
-192
lines changed

.coveragerc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[report]
2+
exclude_lines =
3+
# Have to re-enable the standard pragma
4+
pragma: no cover
5+
6+
# Don't complain if tests don't hit defensive assertion code:
7+
raise NotImplementedError

cloudevents/abstract/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2018-Present The CloudEvents Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
15+
from cloudevents.abstract.event import AnyCloudEvent, CloudEvent # noqa

cloudevents/abstract/event.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Copyright 2018-Present The CloudEvents Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
15+
import typing
16+
from abc import abstractmethod
17+
from typing import TypeVar
18+
19+
20+
class CloudEvent:
21+
"""
22+
The CloudEvent Python wrapper contract exposing generically-available
23+
properties and APIs.
24+
25+
Implementations might handle fields and have other APIs exposed but are
26+
obliged to follow this contract.
27+
"""
28+
29+
@classmethod
30+
def create(
31+
cls,
32+
attributes: typing.Dict[str, typing.Any],
33+
data: typing.Optional[typing.Any],
34+
) -> "AnyCloudEvent":
35+
"""
36+
Creates a new instance of the CloudEvent using supplied `attributes`
37+
and `data`.
38+
39+
This method should be preferably used over the constructor to create events
40+
while custom framework-specific implementations may require or assume
41+
different arguments.
42+
43+
:param attributes: The attributes of the CloudEvent instance.
44+
:param data: The payload of the CloudEvent instance.
45+
:returns: A new instance of the CloudEvent created from the passed arguments.
46+
"""
47+
raise NotImplementedError()
48+
49+
@abstractmethod
50+
def _get_attributes(self) -> typing.Dict[str, typing.Any]:
51+
"""
52+
Returns the attributes of the event.
53+
54+
The implementation MUST assume that the returned value MAY be mutated.
55+
56+
Having a function over a property simplifies integration for custom
57+
framework-specific implementations.
58+
59+
:returns: Attributes of the event.
60+
"""
61+
raise NotImplementedError()
62+
63+
@abstractmethod
64+
def _get_data(self) -> typing.Optional[typing.Any]:
65+
"""
66+
Returns the data of the event.
67+
68+
The implementation MUST assume that the returned value MAY be mutated.
69+
70+
Having a function over a property simplifies integration for custom
71+
framework-specific implementations.
72+
73+
:returns: Data of the event.
74+
"""
75+
raise NotImplementedError()
76+
77+
def __eq__(self, other: typing.Any) -> bool:
78+
if isinstance(other, CloudEvent):
79+
same_data = self._get_data() == other._get_data()
80+
same_attributes = self._get_attributes() == other._get_attributes()
81+
return same_data and same_attributes
82+
return False
83+
84+
def __getitem__(self, key: str) -> typing.Any:
85+
"""
86+
Returns a value of an attribute of the event denoted by the given `key`.
87+
88+
The `data` of the event should be accessed by the `.data` accessor rather
89+
than this mapping.
90+
91+
:param key: The name of the event attribute to retrieve the value for.
92+
:returns: The event attribute value.
93+
"""
94+
return self._get_attributes()[key]
95+
96+
def get(
97+
self, key: str, default: typing.Optional[typing.Any] = None
98+
) -> typing.Optional[typing.Any]:
99+
"""
100+
Retrieves an event attribute value for the given `key`.
101+
102+
Returns the `default` value if the attribute for the given key does not exist.
103+
104+
The implementation MUST NOT throw an error when the key does not exist, but
105+
rather should return `None` or the configured `default`.
106+
107+
:param key: The name of the event attribute to retrieve the value for.
108+
:param default: The default value to be returned when
109+
no attribute with the given key exists.
110+
:returns: The event attribute value if exists, default value or None otherwise.
111+
"""
112+
return self._get_attributes().get(key, default)
113+
114+
def __iter__(self) -> typing.Iterator[typing.Any]:
115+
"""
116+
Returns an iterator over the event attributes.
117+
"""
118+
return iter(self._get_attributes())
119+
120+
def __len__(self) -> int:
121+
"""
122+
Returns the number of the event attributes.
123+
"""
124+
return len(self._get_attributes())
125+
126+
def __contains__(self, key: str) -> bool:
127+
"""
128+
Determines if an attribute with a given `key` is present
129+
in the event attributes.
130+
"""
131+
return key in self._get_attributes()
132+
133+
def __repr__(self) -> str:
134+
return str({"attributes": self._get_attributes(), "data": self._get_data()})
135+
136+
137+
AnyCloudEvent = TypeVar("AnyCloudEvent", bound=CloudEvent)

cloudevents/conversion.py

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Copyright 2018-Present The CloudEvents Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
# not use this file except in compliance with the License. You may obtain
5+
# a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
# License for the specific language governing permissions and limitations
13+
# under the License.
14+
#
15+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
16+
# not use this file except in compliance with the License. You may obtain
17+
# a copy of the License at
18+
#
19+
# http://www.apache.org/licenses/LICENSE-2.0
20+
#
21+
# Unless required by applicable law or agreed to in writing, software
22+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
23+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
24+
# License for the specific language governing permissions and limitations
25+
# under the License.
26+
import json
27+
import typing
28+
29+
from cloudevents import exceptions as cloud_exceptions
30+
from cloudevents.abstract import AnyCloudEvent
31+
from cloudevents.http import is_binary
32+
from cloudevents.http.mappings import _marshaller_by_format, _obj_by_version
33+
from cloudevents.http.util import _json_or_string
34+
from cloudevents.sdk import converters, marshaller, types
35+
36+
37+
def to_json(
38+
event: AnyCloudEvent,
39+
data_marshaller: types.MarshallerType = None,
40+
) -> typing.Union[str, bytes]:
41+
"""
42+
Converts given `event` to a JSON string.
43+
44+
:param event: A CloudEvent to be converted into a JSON string.
45+
:param data_marshaller: Callable function which will cast `event.data`
46+
into a JSON string.
47+
:returns: A JSON string representing the given event.
48+
"""
49+
return to_structured(event, data_marshaller=data_marshaller)[1]
50+
51+
52+
def from_json(
53+
event_type: typing.Type[AnyCloudEvent],
54+
data: typing.Union[str, bytes],
55+
data_unmarshaller: types.UnmarshallerType = None,
56+
) -> AnyCloudEvent:
57+
"""
58+
Parses JSON string `data` into a CloudEvent.
59+
60+
:param data: JSON string representation of a CloudEvent.
61+
:param data_unmarshaller: Callable function that casts `data` to a
62+
Python object.
63+
:param event_type: A concrete type of the event into which the data is
64+
deserialized.
65+
:returns: A CloudEvent parsed from the given JSON representation.
66+
"""
67+
return from_http(
68+
headers={},
69+
data=data,
70+
data_unmarshaller=data_unmarshaller,
71+
event_type=event_type,
72+
)
73+
74+
75+
def from_http(
76+
event_type: typing.Type[AnyCloudEvent],
77+
headers: typing.Dict[str, str],
78+
data: typing.Union[str, bytes, None],
79+
data_unmarshaller: types.UnmarshallerType = None,
80+
) -> AnyCloudEvent:
81+
"""
82+
Parses CloudEvent `data` and `headers` into an instance of a given `event_type`.
83+
84+
The method supports both binary and structured representations.
85+
86+
:param headers: The HTTP request headers.
87+
:param data: The HTTP request body. If set to None, "" or b'', the returned
88+
event's `data` field will be set to None.
89+
:param data_unmarshaller: Callable function to map data to a python object
90+
e.g. lambda x: x or lambda x: json.loads(x)
91+
:param event_type: The actual type of CloudEvent to deserialize the event to.
92+
:returns: A CloudEvent instance parsed from the passed HTTP parameters of
93+
the specified type.
94+
"""
95+
if data is None or data == b"":
96+
# Empty string will cause data to be marshalled into None
97+
data = ""
98+
99+
if not isinstance(data, (str, bytes, bytearray)):
100+
raise cloud_exceptions.InvalidStructuredJSON(
101+
"Expected json of type (str, bytes, bytearray), "
102+
f"but instead found type {type(data)}"
103+
)
104+
105+
headers = {key.lower(): value for key, value in headers.items()}
106+
if data_unmarshaller is None:
107+
data_unmarshaller = _json_or_string
108+
109+
marshall = marshaller.NewDefaultHTTPMarshaller()
110+
111+
if is_binary(headers):
112+
specversion = headers.get("ce-specversion", None)
113+
else:
114+
try:
115+
raw_ce = json.loads(data)
116+
except json.decoder.JSONDecodeError:
117+
raise cloud_exceptions.MissingRequiredFields(
118+
"Failed to read specversion from both headers and data. "
119+
f"The following can not be parsed as json: {data}"
120+
)
121+
if hasattr(raw_ce, "get"):
122+
specversion = raw_ce.get("specversion", None)
123+
else:
124+
raise cloud_exceptions.MissingRequiredFields(
125+
"Failed to read specversion from both headers and data. "
126+
f"The following deserialized data has no 'get' method: {raw_ce}"
127+
)
128+
129+
if specversion is None:
130+
raise cloud_exceptions.MissingRequiredFields(
131+
"Failed to find specversion in HTTP request"
132+
)
133+
134+
event_handler = _obj_by_version.get(specversion, None)
135+
136+
if event_handler is None:
137+
raise cloud_exceptions.InvalidRequiredFields(
138+
f"Found invalid specversion {specversion}"
139+
)
140+
141+
event = marshall.FromRequest(
142+
event_handler(), headers, data, data_unmarshaller=data_unmarshaller
143+
)
144+
attrs = event.Properties()
145+
attrs.pop("data", None)
146+
attrs.pop("extensions", None)
147+
attrs.update(**event.extensions)
148+
149+
if event.data == "" or event.data == b"":
150+
# TODO: Check binary unmarshallers to debug why setting data to ""
151+
# returns an event with data set to None, but structured will return ""
152+
data = None
153+
else:
154+
data = event.data
155+
return event_type.create(attrs, data)
156+
157+
158+
def _to_http(
159+
event: AnyCloudEvent,
160+
format: str = converters.TypeStructured,
161+
data_marshaller: types.MarshallerType = None,
162+
) -> typing.Tuple[dict, typing.Union[bytes, str]]:
163+
"""
164+
Returns a tuple of HTTP headers/body dicts representing this Cloud Event.
165+
166+
:param format: The encoding format of the event.
167+
:param data_marshaller: Callable function that casts event.data into
168+
either a string or bytes.
169+
:returns: (http_headers: dict, http_body: bytes or str)
170+
"""
171+
if data_marshaller is None:
172+
data_marshaller = _marshaller_by_format[format]
173+
174+
if event["specversion"] not in _obj_by_version:
175+
raise cloud_exceptions.InvalidRequiredFields(
176+
f"Unsupported specversion: {event['specversion']}"
177+
)
178+
179+
event_handler = _obj_by_version[event["specversion"]]()
180+
for attribute_name in event:
181+
event_handler.Set(attribute_name, event[attribute_name])
182+
event_handler.data = event.data
183+
184+
return marshaller.NewDefaultHTTPMarshaller().ToRequest(
185+
event_handler, format, data_marshaller=data_marshaller
186+
)
187+
188+
189+
def to_structured(
190+
event: AnyCloudEvent,
191+
data_marshaller: types.MarshallerType = None,
192+
) -> typing.Tuple[dict, typing.Union[bytes, str]]:
193+
"""
194+
Returns a tuple of HTTP headers/body dicts representing this Cloud Event.
195+
196+
If event.data is a byte object, body will have a `data_base64` field instead of
197+
`data`.
198+
199+
:param event: The event to be converted.
200+
:param data_marshaller: Callable function to cast event.data into
201+
either a string or bytes
202+
:returns: (http_headers: dict, http_body: bytes or str)
203+
"""
204+
return _to_http(event=event, data_marshaller=data_marshaller)
205+
206+
207+
def to_binary(
208+
event: AnyCloudEvent, data_marshaller: types.MarshallerType = None
209+
) -> typing.Tuple[dict, typing.Union[bytes, str]]:
210+
"""
211+
Returns a tuple of HTTP headers/body dicts representing this Cloud Event.
212+
213+
Uses Binary conversion format.
214+
215+
:param event: The event to be converted.
216+
:param data_marshaller: Callable function to cast event.data into
217+
either a string or bytes.
218+
:returns: (http_headers: dict, http_body: bytes or str)
219+
"""
220+
return _to_http(
221+
event=event,
222+
format=converters.TypeBinary,
223+
data_marshaller=data_marshaller,
224+
)

0 commit comments

Comments
 (0)