Skip to content

Commit e78a70b

Browse files
committed
chore: Improve exceptions handling. Have exceptions grouped by attribute name and typed exceptions
Signed-off-by: Tudor Plugaru <[email protected]>
1 parent 6e13f72 commit e78a70b

File tree

3 files changed

+301
-103
lines changed

3 files changed

+301
-103
lines changed

src/cloudevents/core/v1/event.py

Lines changed: 93 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,17 @@
1313
# under the License.
1414

1515
import re
16+
from collections import defaultdict
1617
from datetime import datetime
1718
from typing import Any, Final, Optional
1819

19-
from cloudevents.core.v1.exceptions import CloudEventValidationError
20+
from cloudevents.core.v1.exceptions import (
21+
BaseCloudEventException,
22+
CloudEventValidationError,
23+
CustomExtensionAttributeError,
24+
InvalidAttributeTypeError,
25+
MissingRequiredAttributeError,
26+
)
2027

2128
REQUIRED_ATTRIBUTES: Final[list[str]] = ["id", "source", "type", "specversion"]
2229
OPTIONAL_ATTRIBUTES: Final[list[str]] = [
@@ -57,128 +64,157 @@ def _validate_attribute(attributes: dict[str, Any]) -> None:
5764
5865
See https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#required-attributes
5966
"""
60-
errors = {}
67+
errors: dict[str, list] = defaultdict(list)
6168
errors.update(CloudEvent._validate_required_attributes(attributes))
62-
errors.update(CloudEvent._validate_attribute_types(attributes))
6369
errors.update(CloudEvent._validate_optional_attributes(attributes))
6470
errors.update(CloudEvent._validate_extension_attributes(attributes))
6571
if errors:
66-
raise CloudEventValidationError(errors)
72+
raise CloudEventValidationError(dict(errors))
6773

6874
@staticmethod
6975
def _validate_required_attributes(
7076
attributes: dict[str, Any],
71-
) -> dict[str, list[str]]:
72-
"""
73-
Validates that all required attributes are present.
74-
75-
:param attributes: The attributes of the CloudEvent instance.
76-
:return: A dictionary of validation error messages.
77-
"""
78-
errors = {}
79-
missing_attributes = [
80-
attr for attr in REQUIRED_ATTRIBUTES if attr not in attributes
81-
]
82-
if missing_attributes:
83-
errors["required"] = [
84-
f"Missing required attribute(s): {', '.join(missing_attributes)}"
85-
]
86-
return errors
87-
88-
@staticmethod
89-
def _validate_attribute_types(attributes: dict[str, Any]) -> dict[str, list[str]]:
77+
) -> dict[str, list[BaseCloudEventException]]:
9078
"""
9179
Validates the types of the required attributes.
9280
9381
:param attributes: The attributes of the CloudEvent instance.
9482
:return: A dictionary of validation error messages.
9583
"""
96-
errors = {}
97-
type_errors = []
84+
errors = defaultdict(list)
85+
86+
if "id" not in attributes:
87+
errors["id"].append(MissingRequiredAttributeError(missing="id"))
9888
if attributes.get("id") is None:
99-
type_errors.append("Attribute 'id' must not be None")
89+
errors["id"].append(
90+
InvalidAttributeTypeError("Attribute 'id' must not be None")
91+
)
10092
if not isinstance(attributes.get("id"), str):
101-
type_errors.append("Attribute 'id' must be a string")
93+
errors["id"].append(
94+
InvalidAttributeTypeError("Attribute 'id' must be a string")
95+
)
96+
97+
if "source" not in attributes:
98+
errors["source"].append(MissingRequiredAttributeError(missing="source"))
10299
if not isinstance(attributes.get("source"), str):
103-
type_errors.append("Attribute 'source' must be a string")
100+
errors["source"].append(
101+
InvalidAttributeTypeError("Attribute 'source' must be a string")
102+
)
103+
104+
if "type" not in attributes:
105+
errors["type"].append(MissingRequiredAttributeError(missing="type"))
104106
if not isinstance(attributes.get("type"), str):
105-
type_errors.append("Attribute 'type' must be a string")
107+
errors["type"].append(
108+
InvalidAttributeTypeError("Attribute 'type' must be a string")
109+
)
110+
111+
if "specversion" not in attributes:
112+
errors["specversion"].append(
113+
MissingRequiredAttributeError(missing="specversion")
114+
)
106115
if not isinstance(attributes.get("specversion"), str):
107-
type_errors.append("Attribute 'specversion' must be a string")
116+
errors["specversion"].append(
117+
InvalidAttributeTypeError("Attribute 'specversion' must be a string")
118+
)
108119
if attributes.get("specversion") != "1.0":
109-
type_errors.append("Attribute 'specversion' must be '1.0'")
110-
if type_errors:
111-
errors["type"] = type_errors
120+
errors["specversion"].append(
121+
InvalidAttributeTypeError("Attribute 'specversion' must be '1.0'")
122+
)
112123
return errors
113124

114125
@staticmethod
115126
def _validate_optional_attributes(
116127
attributes: dict[str, Any],
117-
) -> dict[str, list[str]]:
128+
) -> dict[str, list[BaseCloudEventException]]:
118129
"""
119130
Validates the types and values of the optional attributes.
120131
121132
:param attributes: The attributes of the CloudEvent instance.
122133
:return: A dictionary of validation error messages.
123134
"""
124-
errors = {}
125-
optional_errors = []
135+
errors = defaultdict(list)
136+
126137
if "time" in attributes:
127138
if not isinstance(attributes["time"], datetime):
128-
optional_errors.append("Attribute 'time' must be a datetime object")
139+
errors["time"].append(
140+
InvalidAttributeTypeError(
141+
"Attribute 'time' must be a datetime object"
142+
)
143+
)
129144
if hasattr(attributes["time"], "tzinfo") and not attributes["time"].tzinfo:
130-
optional_errors.append("Attribute 'time' must be timezone aware")
145+
errors["time"].append(
146+
InvalidAttributeTypeError("Attribute 'time' must be timezone aware")
147+
)
131148
if "subject" in attributes:
132149
if not isinstance(attributes["subject"], str):
133-
optional_errors.append("Attribute 'subject' must be a string")
150+
errors["subject"].append(
151+
InvalidAttributeTypeError("Attribute 'subject' must be a string")
152+
)
134153
if not attributes["subject"]:
135-
optional_errors.append("Attribute 'subject' must not be empty")
154+
errors["subject"].append(
155+
InvalidAttributeTypeError("Attribute 'subject' must not be empty")
156+
)
136157
if "datacontenttype" in attributes:
137158
if not isinstance(attributes["datacontenttype"], str):
138-
optional_errors.append("Attribute 'datacontenttype' must be a string")
159+
errors["datacontenttype"].append(
160+
InvalidAttributeTypeError(
161+
"Attribute 'datacontenttype' must be a string"
162+
)
163+
)
139164
if not attributes["datacontenttype"]:
140-
optional_errors.append("Attribute 'datacontenttype' must not be empty")
165+
errors["datacontenttype"].append(
166+
InvalidAttributeTypeError(
167+
"Attribute 'datacontenttype' must not be empty"
168+
)
169+
)
141170
if "dataschema" in attributes:
142171
if not isinstance(attributes["dataschema"], str):
143-
optional_errors.append("Attribute 'dataschema' must be a string")
172+
errors["dataschema"].append(
173+
InvalidAttributeTypeError("Attribute 'dataschema' must be a string")
174+
)
144175
if not attributes["dataschema"]:
145-
optional_errors.append("Attribute 'dataschema' must not be empty")
146-
if optional_errors:
147-
errors["optional"] = optional_errors
176+
errors["dataschema"].append(
177+
InvalidAttributeTypeError(
178+
"Attribute 'dataschema' must not be empty"
179+
)
180+
)
148181
return errors
149182

150183
@staticmethod
151184
def _validate_extension_attributes(
152185
attributes: dict[str, Any],
153-
) -> dict[str, list[str]]:
186+
) -> dict[str, list[BaseCloudEventException]]:
154187
"""
155188
Validates the extension attributes.
156189
157190
:param attributes: The attributes of the CloudEvent instance.
158191
:return: A dictionary of validation error messages.
159192
"""
160-
errors = {}
161-
extension_errors = []
193+
errors = defaultdict(list)
162194
extension_attributes = [
163195
key
164196
for key in attributes.keys()
165197
if key not in REQUIRED_ATTRIBUTES and key not in OPTIONAL_ATTRIBUTES
166198
]
167199
for extension_attribute in extension_attributes:
168200
if extension_attribute == "data":
169-
extension_errors.append(
170-
"Extension attribute 'data' is reserved and must not be used"
201+
errors[extension_attribute].append(
202+
CustomExtensionAttributeError(
203+
"Extension attribute 'data' is reserved and must not be used"
204+
)
171205
)
172206
if not (1 <= len(extension_attribute) <= 20):
173-
extension_errors.append(
174-
f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long"
207+
errors[extension_attribute].append(
208+
CustomExtensionAttributeError(
209+
f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long"
210+
)
175211
)
176212
if not re.match(r"^[a-z0-9]+$", extension_attribute):
177-
extension_errors.append(
178-
f"Extension attribute '{extension_attribute}' should only contain lowercase letters and numbers"
213+
errors[extension_attribute].append(
214+
CustomExtensionAttributeError(
215+
f"Extension attribute '{extension_attribute}' should only contain lowercase letters and numbers"
216+
)
179217
)
180-
if extension_errors:
181-
errors["extensions"] = extension_errors
182218
return errors
183219

184220
def get_id(self) -> str:

src/cloudevents/core/v1/exceptions.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,46 @@
1111
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1212
# License for the specific language governing permissions and limitations
1313
# under the License.
14-
class CloudEventValidationError(Exception):
14+
class BaseCloudEventException(Exception):
15+
pass
16+
17+
18+
class CloudEventValidationError(BaseCloudEventException):
1519
"""
16-
Custom exception for validation errors.
20+
Holds validation errors aggregated during a CloudEvent creation.
1721
"""
1822

19-
def __init__(self, errors: dict[str, list[str]]) -> None:
23+
def __init__(self, errors: dict[str, list[BaseCloudEventException]]) -> None:
24+
"""
25+
:param errors: The errors gathered during the CloudEvent creation where key
26+
is the name of the attribute and value is a list of errors related to that attribute.
27+
"""
2028
super().__init__("Validation errors occurred")
21-
self.errors: dict[str, list[str]] = errors
29+
self.errors: dict[str, list[BaseCloudEventException]] = errors
2230

2331
def __str__(self) -> str:
2432
error_messages = [
25-
f"{key}: {', '.join(value)}" for key, value in self.errors.items()
33+
f"{key}: {', '.join(str(value))}" for key, value in self.errors.items()
2634
]
2735
return f"{super().__str__()}: {', '.join(error_messages)}"
36+
37+
38+
class MissingRequiredAttributeError(BaseCloudEventException):
39+
"""
40+
Exception for missing required attribute.
41+
"""
42+
43+
def __init__(self, missing: str) -> None:
44+
super().__init__(f"Missing required attribute: '{missing}'")
45+
46+
47+
class CustomExtensionAttributeError(BaseCloudEventException):
48+
"""
49+
Exception for invalid custom extension names.
50+
"""
51+
52+
53+
class InvalidAttributeTypeError(BaseCloudEventException):
54+
"""
55+
Exception for invalid attribute type.
56+
"""

0 commit comments

Comments
 (0)