Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: base CloudEvent class as per v1 specs, including attribute validation #242

Merged
merged 20 commits into from
Nov 16, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a2ac762
feat: base `CloudEvent` class as per v1 specs, including attribute va…
PlugaruT Nov 8, 2024
8db1e29
chore: add typings and docstrings
PlugaruT Nov 8, 2024
35dee7d
chore: Add support for custom extension names and validate them
PlugaruT Nov 9, 2024
42b4fe1
chore: Add copyright and fix missing type info
PlugaruT Nov 9, 2024
f83c363
chore: Add getters for attributes and test happy path
PlugaruT Nov 9, 2024
9d1aa35
fix: typing
PlugaruT Nov 9, 2024
aa81ca0
chore: Split validation logic into smaller methods
PlugaruT Nov 11, 2024
b2b0649
chore: Add method to extract extension by name
PlugaruT Nov 11, 2024
b202325
chore: configure ruff to sort imports also
PlugaruT Nov 11, 2024
c5e6df9
chore: Returns all the errors at ones instead of raising early. Impro…
PlugaruT Nov 11, 2024
6e13f72
fix missing type info
PlugaruT Nov 11, 2024
e78a70b
chore: Improve exceptions handling. Have exceptions grouped by attrib…
PlugaruT Nov 13, 2024
443aee9
chore: Skip type checing for getters of required attributes
PlugaruT Nov 13, 2024
1d43d68
fix: missing type
PlugaruT Nov 14, 2024
21493e1
chore: Improve exceptions and introduce a new one for invalid values
PlugaruT Nov 14, 2024
68337f9
fix: str representation for validation error
PlugaruT Nov 14, 2024
d0bba86
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 14, 2024
599d05c
fix: Fix missing type definitions
PlugaruT Nov 14, 2024
43f1d0c
small fix
PlugaruT Nov 14, 2024
7d18098
remove cast of defaultdict to dict
PlugaruT Nov 14, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ exclude = [
[tool.ruff.lint]
ignore = ["E731"]
extend-ignore = ["E203"]
select = ["I"]


[tool.pytest.ini_options]
testpaths = [
Expand Down
17 changes: 17 additions & 0 deletions src/cloudevents/core/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 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.

"""
CloudEvent implementation for v1.0
"""
263 changes: 263 additions & 0 deletions src/cloudevents/core/v1/event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
# 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 datetime import datetime
from typing import Any, Final, Optional

from cloudevents.core.v1.exceptions import CloudEventValidationError

REQUIRED_ATTRIBUTES: Final[list[str]] = ["id", "source", "type", "specversion"]
OPTIONAL_ATTRIBUTES: Final[list[str]] = [
"datacontenttype",
"dataschema",
"subject",
"time",
]


class CloudEvent:
PlugaruT marked this conversation as resolved.
Show resolved Hide resolved
PlugaruT marked this conversation as resolved.
Show resolved Hide resolved
"""
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.
"""
self._validate_attribute(attributes)
self._attributes: dict[str, Any] = attributes
self._data: Optional[dict] = data

@staticmethod
def _validate_attribute(attributes: dict[str, Any]) -> None:
"""
Validates the attributes of the CloudEvent as per the CloudEvents specification.

See https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md#required-attributes
"""
errors = {}
PlugaruT marked this conversation as resolved.
Show resolved Hide resolved
errors.update(CloudEvent._validate_required_attributes(attributes))
errors.update(CloudEvent._validate_attribute_types(attributes))
errors.update(CloudEvent._validate_optional_attributes(attributes))
errors.update(CloudEvent._validate_extension_attributes(attributes))
if errors:
raise CloudEventValidationError(errors)

@staticmethod
def _validate_required_attributes(
attributes: dict[str, Any],
) -> dict[str, list[str]]:
"""
Validates that all required attributes are present.

:param attributes: The attributes of the CloudEvent instance.
:return: A dictionary of validation error messages.
"""
errors = {}
missing_attributes = [
attr for attr in REQUIRED_ATTRIBUTES if attr not in attributes
]
if missing_attributes:
errors["required"] = [
f"Missing required attribute(s): {', '.join(missing_attributes)}"
]
return errors

@staticmethod
def _validate_attribute_types(attributes: dict[str, Any]) -> dict[str, list[str]]:
"""
Validates the types of the required attributes.

:param attributes: The attributes of the CloudEvent instance.
:return: A dictionary of validation error messages.
"""
errors = {}
type_errors = []
if attributes.get("id") is None:
type_errors.append("Attribute 'id' must not be None")
if not isinstance(attributes.get("id"), str):
type_errors.append("Attribute 'id' must be a string")
if not isinstance(attributes.get("source"), str):
type_errors.append("Attribute 'source' must be a string")
if not isinstance(attributes.get("type"), str):
type_errors.append("Attribute 'type' must be a string")
if not isinstance(attributes.get("specversion"), str):
type_errors.append("Attribute 'specversion' must be a string")
if attributes.get("specversion") != "1.0":
type_errors.append("Attribute 'specversion' must be '1.0'")
if type_errors:
errors["type"] = type_errors
return errors

@staticmethod
def _validate_optional_attributes(
attributes: dict[str, Any],
) -> dict[str, list[str]]:
"""
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 = {}
optional_errors = []
if "time" in attributes:
if not isinstance(attributes["time"], datetime):
optional_errors.append("Attribute 'time' must be a datetime object")
if hasattr(attributes["time"], "tzinfo") and not attributes["time"].tzinfo:
optional_errors.append("Attribute 'time' must be timezone aware")
if "subject" in attributes:
if not isinstance(attributes["subject"], str):
optional_errors.append("Attribute 'subject' must be a string")
if not attributes["subject"]:
optional_errors.append("Attribute 'subject' must not be empty")
if "datacontenttype" in attributes:
if not isinstance(attributes["datacontenttype"], str):
optional_errors.append("Attribute 'datacontenttype' must be a string")
if not attributes["datacontenttype"]:
optional_errors.append("Attribute 'datacontenttype' must not be empty")
if "dataschema" in attributes:
if not isinstance(attributes["dataschema"], str):
optional_errors.append("Attribute 'dataschema' must be a string")
if not attributes["dataschema"]:
optional_errors.append("Attribute 'dataschema' must not be empty")
if optional_errors:
errors["optional"] = optional_errors
return errors

@staticmethod
def _validate_extension_attributes(
attributes: dict[str, Any],
) -> dict[str, list[str]]:
"""
Validates the extension attributes.

:param attributes: The attributes of the CloudEvent instance.
:return: A dictionary of validation error messages.
"""
errors = {}
extension_errors = []
PlugaruT marked this conversation as resolved.
Show resolved Hide resolved
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":
extension_errors.append(
"Extension attribute 'data' is reserved and must not be used"
)
if not (1 <= len(extension_attribute) <= 20):
extension_errors.append(
f"Extension attribute '{extension_attribute}' should be between 1 and 20 characters long"
)
if not re.match(r"^[a-z0-9]+$", extension_attribute):
extension_errors.append(
f"Extension attribute '{extension_attribute}' should only contain lowercase letters and numbers"
)
if extension_errors:
errors["extensions"] = extension_errors
return errors

def get_id(self) -> str:
"""
Retrieve the ID of the event.

:return: The ID of the event.
"""
return self._attributes["id"]

def get_source(self) -> str:
"""
Retrieve the source of the event.

:return: The source of the event.
"""
return self._attributes["source"]

def get_type(self) -> str:
"""
Retrieve the type of the event.

:return: The type of the event.
"""
return self._attributes["type"]

def get_specversion(self) -> str:
"""
Retrieve the specversion of the event.

:return: The specversion of the event.
"""
return self._attributes["specversion"]

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.
"""
return self._data
27 changes: 27 additions & 0 deletions src/cloudevents/core/v1/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 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.
class CloudEventValidationError(Exception):
"""
Custom exception for validation errors.
"""

def __init__(self, errors: dict[str, list[str]]) -> None:
super().__init__("Validation errors occurred")
PlugaruT marked this conversation as resolved.
Show resolved Hide resolved
self.errors: dict[str, list[str]] = errors

def __str__(self) -> str:
error_messages = [
f"{key}: {', '.join(value)}" for key, value in self.errors.items()
]
return f"{super().__str__()}: {', '.join(error_messages)}"
PlugaruT marked this conversation as resolved.
Show resolved Hide resolved
13 changes: 13 additions & 0 deletions tests/test_core/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions tests/test_core/test_v1/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
Loading