Skip to content

Commit a73c870

Browse files
feat: base CloudEvent class as per v1 specs, including attribute validation (#242)
* feat: base `CloudEvent` class as per v1 specs, including attribute validation Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * chore: add typings and docstrings Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * chore: Add support for custom extension names and validate them Signed-off-by: Tudor <plugaru.tudor@protonmail.com> * chore: Add copyright and fix missing type info Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * chore: Add getters for attributes and test happy path Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * fix: typing Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * chore: Split validation logic into smaller methods Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * chore: Add method to extract extension by name Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * chore: configure ruff to sort imports also Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * chore: Returns all the errors at ones instead of raising early. Improve tests Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * fix missing type info Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * chore: Improve exceptions handling. Have exceptions grouped by attribute name and typed exceptions Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * chore: Skip type checing for getters of required attributes We can't use TypedDict here becuase it does not allow for arbitrary keys which we need in order to support custom extension attributes. Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * fix: missing type Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * chore: Improve exceptions and introduce a new one for invalid values Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * fix: str representation for validation error Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: Fix missing type definitions Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * small fix Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> * remove cast of defaultdict to dict Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> --------- Signed-off-by: Tudor Plugaru <plugaru.tudor@protonmail.com> Signed-off-by: Tudor <plugaru.tudor@protonmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent d601888 commit a73c870

File tree

7 files changed

+778
-0
lines changed

7 files changed

+778
-0
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ exclude = [
111111
[tool.ruff.lint]
112112
ignore = ["E731"]
113113
extend-ignore = ["E203"]
114+
select = ["I"]
115+
114116

115117
[tool.pytest.ini_options]
116118
testpaths = [

src/cloudevents/core/v1/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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+
"""
16+
CloudEvent implementation for v1.0
17+
"""

src/cloudevents/core/v1/event.py

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
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 re
16+
from collections import defaultdict
17+
from datetime import datetime
18+
from typing import Any, Final, Optional
19+
20+
from cloudevents.core.v1.exceptions import (
21+
BaseCloudEventException,
22+
CloudEventValidationError,
23+
CustomExtensionAttributeError,
24+
InvalidAttributeTypeError,
25+
InvalidAttributeValueError,
26+
MissingRequiredAttributeError,
27+
)
28+
29+
REQUIRED_ATTRIBUTES: Final[list[str]] = ["id", "source", "type", "specversion"]
30+
OPTIONAL_ATTRIBUTES: Final[list[str]] = [
31+
"datacontenttype",
32+
"dataschema",
33+
"subject",
34+
"time",
35+
]
36+
37+
38+
class CloudEvent:
39+
"""
40+
The CloudEvent Python wrapper contract exposing generically-available
41+
properties and APIs.
42+
43+
Implementations might handle fields and have other APIs exposed but are
44+
obliged to follow this contract.
45+
"""
46+
47+
def __init__(self, attributes: dict[str, Any], data: Optional[dict] = None) -> None:
48+
"""
49+
Create a new CloudEvent instance.
50+
51+
:param attributes: The attributes of the CloudEvent instance.
52+
:param data: The payload of the CloudEvent instance.
53+
54+
:raises ValueError: If any of the required attributes are missing or have invalid values.
55+
:raises TypeError: If any of the attributes have invalid types.
56+
"""
57+
self._validate_attribute(attributes=attributes)
58+
self._attributes: dict[str, Any] = attributes
59+
self._data: Optional[dict] = data
60+
61+
@staticmethod
62+
def _validate_attribute(attributes: dict[str, Any]) -> None:
63+
"""
64+
Validates the attributes of the CloudEvent as per the CloudEvents specification.
65+
66+
See https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#required-attributes
67+
"""
68+
errors: dict[str, list[BaseCloudEventException]] = defaultdict(list)
69+
errors.update(CloudEvent._validate_required_attributes(attributes=attributes))
70+
errors.update(CloudEvent._validate_optional_attributes(attributes=attributes))
71+
errors.update(CloudEvent._validate_extension_attributes(attributes=attributes))
72+
if errors:
73+
raise CloudEventValidationError(errors=errors)
74+
75+
@staticmethod
76+
def _validate_required_attributes(
77+
attributes: dict[str, Any],
78+
) -> dict[str, list[BaseCloudEventException]]:
79+
"""
80+
Validates the types of the required attributes.
81+
82+
:param attributes: The attributes of the CloudEvent instance.
83+
:return: A dictionary of validation error messages.
84+
"""
85+
errors = defaultdict(list)
86+
87+
if "id" not in attributes:
88+
errors["id"].append(MissingRequiredAttributeError(attribute_name="id"))
89+
if attributes.get("id") is None:
90+
errors["id"].append(
91+
InvalidAttributeValueError(
92+
attribute_name="id", msg="Attribute 'id' must not be None"
93+
)
94+
)
95+
if not isinstance(attributes.get("id"), str):
96+
errors["id"].append(
97+
InvalidAttributeTypeError(attribute_name="id", expected_type=str)
98+
)
99+
100+
if "source" not in attributes:
101+
errors["source"].append(
102+
MissingRequiredAttributeError(attribute_name="source")
103+
)
104+
if not isinstance(attributes.get("source"), str):
105+
errors["source"].append(
106+
InvalidAttributeTypeError(attribute_name="source", expected_type=str)
107+
)
108+
109+
if "type" not in attributes:
110+
errors["type"].append(MissingRequiredAttributeError(attribute_name="type"))
111+
if not isinstance(attributes.get("type"), str):
112+
errors["type"].append(
113+
InvalidAttributeTypeError(attribute_name="type", expected_type=str)
114+
)
115+
116+
if "specversion" not in attributes:
117+
errors["specversion"].append(
118+
MissingRequiredAttributeError(attribute_name="specversion")
119+
)
120+
if not isinstance(attributes.get("specversion"), str):
121+
errors["specversion"].append(
122+
InvalidAttributeTypeError(
123+
attribute_name="specversion", expected_type=str
124+
)
125+
)
126+
if attributes.get("specversion") != "1.0":
127+
errors["specversion"].append(
128+
InvalidAttributeValueError(
129+
attribute_name="specversion",
130+
msg="Attribute 'specversion' must be '1.0'",
131+
)
132+
)
133+
return errors
134+
135+
@staticmethod
136+
def _validate_optional_attributes(
137+
attributes: dict[str, Any],
138+
) -> dict[str, list[BaseCloudEventException]]:
139+
"""
140+
Validates the types and values of the optional attributes.
141+
142+
:param attributes: The attributes of the CloudEvent instance.
143+
:return: A dictionary of validation error messages.
144+
"""
145+
errors = defaultdict(list)
146+
147+
if "time" in attributes:
148+
if not isinstance(attributes["time"], datetime):
149+
errors["time"].append(
150+
InvalidAttributeTypeError(
151+
attribute_name="time", expected_type=datetime
152+
)
153+
)
154+
if hasattr(attributes["time"], "tzinfo") and not attributes["time"].tzinfo:
155+
errors["time"].append(
156+
InvalidAttributeValueError(
157+
attribute_name="time",
158+
msg="Attribute 'time' must be timezone aware",
159+
)
160+
)
161+
if "subject" in attributes:
162+
if not isinstance(attributes["subject"], str):
163+
errors["subject"].append(
164+
InvalidAttributeTypeError(
165+
attribute_name="subject", expected_type=str
166+
)
167+
)
168+
if not attributes["subject"]:
169+
errors["subject"].append(
170+
InvalidAttributeValueError(
171+
attribute_name="subject",
172+
msg="Attribute 'subject' must not be empty",
173+
)
174+
)
175+
if "datacontenttype" in attributes:
176+
if not isinstance(attributes["datacontenttype"], str):
177+
errors["datacontenttype"].append(
178+
InvalidAttributeTypeError(
179+
attribute_name="datacontenttype", expected_type=str
180+
)
181+
)
182+
if not attributes["datacontenttype"]:
183+
errors["datacontenttype"].append(
184+
InvalidAttributeValueError(
185+
attribute_name="datacontenttype",
186+
msg="Attribute 'datacontenttype' must not be empty",
187+
)
188+
)
189+
if "dataschema" in attributes:
190+
if not isinstance(attributes["dataschema"], str):
191+
errors["dataschema"].append(
192+
InvalidAttributeTypeError(
193+
attribute_name="dataschema", expected_type=str
194+
)
195+
)
196+
if not attributes["dataschema"]:
197+
errors["dataschema"].append(
198+
InvalidAttributeValueError(
199+
attribute_name="dataschema",
200+
msg="Attribute 'dataschema' must not be empty",
201+
)
202+
)
203+
return errors
204+
205+
@staticmethod
206+
def _validate_extension_attributes(
207+
attributes: dict[str, Any],
208+
) -> dict[str, list[BaseCloudEventException]]:
209+
"""
210+
Validates the extension attributes.
211+
212+
:param attributes: The attributes of the CloudEvent instance.
213+
:return: A dictionary of validation error messages.
214+
"""
215+
errors = defaultdict(list)
216+
extension_attributes = [
217+
key
218+
for key in attributes.keys()
219+
if key not in REQUIRED_ATTRIBUTES and key not in OPTIONAL_ATTRIBUTES
220+
]
221+
for extension_attribute in extension_attributes:
222+
if extension_attribute == "data":
223+
errors[extension_attribute].append(
224+
CustomExtensionAttributeError(
225+
attribute_name=extension_attribute,
226+
msg="Extension attribute 'data' is reserved and must not be used",
227+
)
228+
)
229+
if not (1 <= len(extension_attribute) <= 20):
230+
errors[extension_attribute].append(
231+
CustomExtensionAttributeError(
232+
attribute_name=extension_attribute,
233+
msg=f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long",
234+
)
235+
)
236+
if not re.match(r"^[a-z0-9]+$", extension_attribute):
237+
errors[extension_attribute].append(
238+
CustomExtensionAttributeError(
239+
attribute_name=extension_attribute,
240+
msg=f"Extension attribute '{extension_attribute}' should only contain lowercase letters and numbers",
241+
)
242+
)
243+
return errors
244+
245+
def get_id(self) -> str:
246+
"""
247+
Retrieve the ID of the event.
248+
249+
:return: The ID of the event.
250+
"""
251+
return self._attributes["id"] # type: ignore
252+
253+
def get_source(self) -> str:
254+
"""
255+
Retrieve the source of the event.
256+
257+
:return: The source of the event.
258+
"""
259+
return self._attributes["source"] # type: ignore
260+
261+
def get_type(self) -> str:
262+
"""
263+
Retrieve the type of the event.
264+
265+
:return: The type of the event.
266+
"""
267+
return self._attributes["type"] # type: ignore
268+
269+
def get_specversion(self) -> str:
270+
"""
271+
Retrieve the specversion of the event.
272+
273+
:return: The specversion of the event.
274+
"""
275+
return self._attributes["specversion"] # type: ignore
276+
277+
def get_datacontenttype(self) -> Optional[str]:
278+
"""
279+
Retrieve the datacontenttype of the event.
280+
281+
:return: The datacontenttype of the event.
282+
"""
283+
return self._attributes.get("datacontenttype")
284+
285+
def get_dataschema(self) -> Optional[str]:
286+
"""
287+
Retrieve the dataschema of the event.
288+
289+
:return: The dataschema of the event.
290+
"""
291+
return self._attributes.get("dataschema")
292+
293+
def get_subject(self) -> Optional[str]:
294+
"""
295+
Retrieve the subject of the event.
296+
297+
:return: The subject of the event.
298+
"""
299+
return self._attributes.get("subject")
300+
301+
def get_time(self) -> Optional[datetime]:
302+
"""
303+
Retrieve the time of the event.
304+
305+
:return: The time of the event.
306+
"""
307+
return self._attributes.get("time")
308+
309+
def get_extension(self, extension_name: str) -> Any:
310+
"""
311+
Retrieve an extension attribute of the event.
312+
313+
:param extension_name: The name of the extension attribute.
314+
:return: The value of the extension attribute.
315+
"""
316+
return self._attributes.get(extension_name)
317+
318+
def get_data(self) -> Optional[dict]:
319+
"""
320+
Retrieve data of the event.
321+
322+
:return: The data of the event.
323+
"""
324+
return self._data

0 commit comments

Comments
 (0)