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

Add cloud event to core #16800

Merged
merged 54 commits into from
Mar 3, 2021
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
abe2389
Add cloud event to core
rakshith91 Feb 18, 2021
9ed935d
extensions
rakshith91 Feb 18, 2021
d11e02f
raise on both
rakshith91 Feb 18, 2021
ec3474c
minor
rakshith91 Feb 18, 2021
e658ce7
more changes
rakshith91 Feb 18, 2021
50de6e6
Update sdk/core/azure-core/azure/core/messaging.py
Feb 18, 2021
9f3624b
comments
rakshith91 Feb 18, 2021
04acd47
changes
rakshith91 Feb 19, 2021
f18f35d
test fix
rakshith91 Feb 19, 2021
9400e0e
test
rakshith91 Feb 19, 2021
70e08c0
comments
rakshith91 Feb 21, 2021
b69cd73
lint
rakshith91 Feb 21, 2021
89c80b5
mypy
rakshith91 Feb 21, 2021
10d81c3
type hint
rakshith91 Feb 22, 2021
a121adc
Apply suggestions from code review
Feb 22, 2021
428e35c
serialize date
rakshith91 Feb 24, 2021
c88c1e9
fix
rakshith91 Feb 24, 2021
f638961
fix
rakshith91 Feb 24, 2021
13335c1
fix
rakshith91 Feb 24, 2021
2dec996
Docstring
lmazuel Feb 24, 2021
c3368d5
change util
rakshith91 Feb 24, 2021
f461890
lint
rakshith91 Feb 24, 2021
248db3d
apply black
rakshith91 Feb 24, 2021
32b2532
utilize tz utc
rakshith91 Feb 25, 2021
2441222
comments
rakshith91 Feb 25, 2021
666bbd7
raise on unexpected kwargs
rakshith91 Feb 25, 2021
45d2a90
doc
rakshith91 Feb 25, 2021
8c1f9fc
lint
rakshith91 Feb 25, 2021
8dcf54c
more lint
rakshith91 Feb 25, 2021
f0d718f
attrs are optional
rakshith91 Feb 26, 2021
950ffed
add sentinel
rakshith91 Feb 26, 2021
c15a73a
falsy object
rakshith91 Feb 28, 2021
a756fe3
few more asserts
rakshith91 Feb 28, 2021
6b4a31f
lint
rakshith91 Feb 28, 2021
9371f77
pyt2 compat
rakshith91 Feb 28, 2021
78696ef
tests
rakshith91 Mar 1, 2021
79e74d4
comments
rakshith91 Mar 1, 2021
a9c05f4
update toc tree
rakshith91 Mar 1, 2021
57b1bd0
doc
rakshith91 Mar 1, 2021
e8b53b0
doc
rakshith91 Mar 1, 2021
3a16ca9
doc
rakshith91 Mar 1, 2021
37e887c
unconditional
rakshith91 Mar 1, 2021
8e196ef
test fix
rakshith91 Mar 1, 2021
b4c726e
mypy
rakshith91 Mar 1, 2021
3f49138
wrong import
rakshith91 Mar 1, 2021
22db8b8
type annotations
rakshith91 Mar 2, 2021
6e9ce14
data
rakshith91 Mar 2, 2021
43a79c1
coment
rakshith91 Mar 2, 2021
ba654a0
assets
rakshith91 Mar 2, 2021
4f9d80e
lint
rakshith91 Mar 2, 2021
0a3aa87
unnecessary none
rakshith91 Mar 3, 2021
5051034
format
rakshith91 Mar 3, 2021
c7afd6e
cast to str
rakshith91 Mar 3, 2021
9613fd4
remove cast
rakshith91 Mar 3, 2021
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
5 changes: 4 additions & 1 deletion sdk/core/azure-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
# Release History

## 1.11.1 (Unreleased)
## 1.12.0 (Unreleased)

### Features

- Added `azure.core.messaging.CloudEvent` model that follows the cloud event spec.

## 1.11.0 (2021-02-08)

Expand Down
31 changes: 31 additions & 0 deletions sdk/core/azure-core/azure/core/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# coding=utf-8
# --------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import datetime


class _FixedOffset(datetime.tzinfo):
lmazuel marked this conversation as resolved.
Show resolved Hide resolved
"""Fixed offset in minutes east from UTC.

Copy/pasted from Python doc

:param int offset: offset in minutes
"""

def __init__(self, offset):
self.__offset = datetime.timedelta(minutes=offset)

def utcoffset(self, dt):
return self.__offset

def tzname(self, dt):
return str(self.__offset.total_seconds()/3600)

def __repr__(self):
return "<FixedOffset {}>".format(self.tzname(None))

def dst(self, dt):
return datetime.timedelta(0)
2 changes: 1 addition & 1 deletion sdk/core/azure-core/azure/core/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
# regenerated.
# --------------------------------------------------------------------------

VERSION = "1.11.1"
VERSION = "1.12.0"
145 changes: 145 additions & 0 deletions sdk/core/azure-core/azure/core/messaging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# coding=utf-8
# --------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import uuid
from base64 import b64decode
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

black the file :)

from datetime import datetime
import isodate

try:
from datetime import timezone
TZ_UTC = timezone.utc # type: ignore
except ImportError:
from azure.core._utils import _FixedOffset
TZ_UTC = _FixedOffset(0) # type: ignore
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make it to _utils, and use it if parsing show Z , which will make the CloudEvent roudtrippable. Otherwise you send TZ_UTC class, but re-parsing back will give a FixedOffset(0) instance.


try:
from typing import TYPE_CHECKING
except ImportError:
TYPE_CHECKING = False

if TYPE_CHECKING:
from typing import Any, Dict

__all__ = ["CloudEvent"]


class CloudEvent(object): #pylint:disable=too-many-instance-attributes
"""Properties of the CloudEvent 1.0 Schema.
All required parameters must be populated in order to send to Azure.
If data is of binary type, data_base64 can be used alternatively. Note that data and data_base64
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite understand this comment. How do I use data_base64 of a CloudEvent instance?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't apply anymore - removed the line

cannot be present at the same time.
:param source: Required. Identifies the context in which an event happened. The combination of id and source must
be unique for each distinct event. If publishing to a domain topic, source must be the domain name.
:type source: str
:param type: Required. Type of event related to the originating occurrence.
:type type: str
:keyword data: Optional. Event data specific to the event type. If data is of bytes type, it will be sent
Copy link
Member

@johanste johanste Feb 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "will be sent as..." comment applies to the event grid sender client, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it can technically be any request
"will be sent as data_base64 in the outgoing request." is the wording here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But there is no code here that will do base64 encoding of the data in this type? I would have understood it better if there was a to_dict method that would convert self.data to 'data_base64': <base 64 encoded self.data>.

In other words, are you trying to document a requirement for the consumer of this data here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm trying to docuement the implementation detail of json serialization

as data_base64 in the outgoing request.
:type data: object
:keyword time: Optional. The time (in UTC) the event was generated.
:type time: ~datetime.datetime
:keyword dataschema: Optional. Identifies the schema that data adheres to.
:type dataschema: str
:keyword datacontenttype: Optional. Content type of data value.
:type datacontenttype: str
:keyword subject: Optional. This describes the subject of the event in the context of the event producer
(identified by source).
:type subject: str
:keyword specversion: Optional. The version of the CloudEvent spec. Defaults to "1.0"
:type specversion: str
:keyword id: Optional. An identifier for the event. The combination of id and source must be
unique for each distinct event. If not provided, a random UUID will be generated and used.
:type id: Optional[str]
:keyword extensions: Optional. A CloudEvent MAY include any number of additional context attributes
with distinct names represented as key - value pairs. Each extension must be alphanumeric, lower cased
and must not exceed the length of 20 characters.
:type extensions: Optional[dict]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional[Dict]?

:ivar source: Identifies the context in which an event happened. The combination of id and source must
be unique for each distinct event. If publishing to a domain topic, source must be the domain name.
:vartype source: str
:ivar data: Event data specific to the event type.
:vartype data: object
:ivar type: Type of event related to the originating occurrence.
:vartype type: str
:ivar time: The time (in UTC) the event was generated.
:vartype time: ~datetime.datetime
:ivar dataschema: Identifies the schema that data adheres to.
:vartype dataschema: str
:ivar datacontenttype: Content type of data value.
:vartype datacontenttype: str
:ivar subject: This describes the subject of the event in the context of the event producer
(identified by source).
:vartype subject: str
:ivar specversion: Optional. The version of the CloudEvent spec. Defaults to "1.0"
:vartype specversion: str
:ivar id: An identifier for the event. The combination of id and source must be
unique for each distinct event. If not provided, a random UUID will be generated and used.
:vartype id: str
:ivar extensions: A CloudEvent MAY include any number of additional context attributes
with distinct names represented as key - value pairs. Each extension must be alphanumeric, lower cased
and must not exceed the length of 20 characters.
:vartype extensions: dict
"""
def __init__(self, source, type, **kwargs): # pylint: disable=redefined-builtin
# type: (str, str, Any) -> None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**Any.

self.source = source
self.type = type
self.specversion = kwargs.pop("specversion", "1.0")
Copy link
Member

@johanste johanste Feb 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are missing type annotations for all instance variables that we pop from kwargs (since kwargs is typed as **Any). We should add them or else static type checkers (e.g. MyPy) will not be able to reason about the type...

self.id = kwargs.pop("id", str(uuid.uuid4()))
self.time = kwargs.pop("time", datetime.now(TZ_UTC))
self.datacontenttype = kwargs.pop("datacontenttype", None)
self.dataschema = kwargs.pop("dataschema", None)
self.subject = kwargs.pop("subject", None)
self.extensions = {}
_extensions = dict(kwargs.pop('extensions', {}))
for key in _extensions.keys():
rakshith91 marked this conversation as resolved.
Show resolved Hide resolved
if not key.islower() or not key.isalnum():
rakshith91 marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError("Extension attributes should be lower cased and alphanumeric.")
self.extensions.update(_extensions)
self.data = kwargs.pop("data", None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect that we raise an error for unexpected (remaining) kwargs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i am not sure it's a good idea - wouldn't this prevent us from having additive changes in the future?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would this prevent us from having additive changes in the future?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, i was thinking on the lines of "Aren't we blocking adding keyword args in the future", but i got it wrong - updated


def __repr__(self):
return (
"CloudEvent(source={}, type={}, specversion={}, id={}, time={})".format(
self.source,
self.type,
self.specversion,
self.id,
self.time
)[:1024]
)

@classmethod
def from_dict(cls, event, **kwargs):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of **kwargs here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no real purpose really - removed it for now - it can be additive

# type: (Dict, Any) -> CloudEvent
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type annotation is incorrect - you will return an instance of cls. Off the top of my head, this would be:

# type: (Type[T], Dict, **Any) -> T

where T is a typing.TypeVar (ideally bound to CloudEvent)

"""
Returns the deserialized CloudEvent object when a dict is provided.
:param event: The dict representation of the event which needs to be deserialized.
:type event: dict
:rtype: CloudEvent
"""
data = event.pop("data", None)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we want to mutate the argument passed in. I would be a bit surprised to notice that my dict had lost (all of its) keys when the method returned.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed this

data_base64 = event.pop("data_base64", None)
if data and data_base64:
raise ValueError("Invalid input. Only one of data and data_base64 must be present.")
try:
time = isodate.parse_datetime(event.pop("time", None))
except AttributeError:
pass
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are using time below - this will generate a UnboundLocalError if you hit this line.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed it

return cls(
id=event.pop("id", None),
source=event.pop("source", None),
type=event.pop("type", None),
specversion=event.pop("specversion", None),
data=data or b64decode(data_base64),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if data is ''?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed this a lil bit - data '' is accepted

time=time,
dataschema=event.pop("dataschema", None),
datacontenttype=event.pop("datacontenttype", None),
subject=event.pop("subject", None),
extensions=event,
**kwargs
)
24 changes: 1 addition & 23 deletions sdk/core/azure-core/azure/core/pipeline/policies/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,29 +26,7 @@
import datetime
import email.utils
from requests.structures import CaseInsensitiveDict

class _FixedOffset(datetime.tzinfo):
"""Fixed offset in minutes east from UTC.

Copy/pasted from Python doc

:param int offset: offset in minutes
"""

def __init__(self, offset):
self.__offset = datetime.timedelta(minutes=offset)

def utcoffset(self, dt):
return self.__offset

def tzname(self, dt):
return str(self.__offset.total_seconds()/3600)

def __repr__(self):
return "<FixedOffset {}>".format(self.tzname(None))

def dst(self, dt):
return datetime.timedelta(0)
from ..._utils import _FixedOffset

def _parse_http_date(text):
"""Parse a HTTP date format into datetime."""
Expand Down
145 changes: 145 additions & 0 deletions sdk/core/azure-core/tests/test_messaging_cloud_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import logging
import sys
import os
import pytest
import json
from datetime import datetime

from azure.core.messaging import CloudEvent

# Cloud Event tests
def test_cloud_event_constructor():
event = CloudEvent(
source='Azure.Core.Sample',
type='SampleType',
data='cloudevent'
)

assert event.specversion == '1.0'
assert event.time.__class__ == datetime
assert event.id is not None
assert event.source == 'Azure.Core.Sample'
assert event.data == 'cloudevent'

def test_cloud_storage_dict():
cloud_storage_dict = {
"id":"a0517898-9fa4-4e70-b4a3-afda1dd68672",
"source":"/subscriptions/{subscription-id}/resourceGroups/{resource-group}/providers/Microsoft.Storage/storageAccounts/{storage-account}",
"data":{
"api":"PutBlockList",
"client_request_id":"6d79dbfb-0e37-4fc4-981f-442c9ca65760",
"request_id":"831e1650-001e-001b-66ab-eeb76e000000",
"e_tag":"0x8D4BCC2E4835CD0",
"content_type":"application/octet-stream",
"content_length":524288,
"blob_type":"BlockBlob",
"url":"https://oc2d2817345i60006.blob.core.windows.net/oc2d2817345i200097container/oc2d2817345i20002296blob",
"sequencer":"00000000000004420000000000028963",
"storage_diagnostics":{"batchId":"b68529f3-68cd-4744-baa4-3c0498ec19f0"}
},
"type":"Microsoft.Storage.BlobCreated",
"time":"2020-08-07T01:11:49.765846Z",
"specversion":"1.0"
}

event = CloudEvent.from_dict(cloud_storage_dict)
assert event.data == {
"api":"PutBlockList",
"client_request_id":"6d79dbfb-0e37-4fc4-981f-442c9ca65760",
"request_id":"831e1650-001e-001b-66ab-eeb76e000000",
"e_tag":"0x8D4BCC2E4835CD0",
"content_type":"application/octet-stream",
"content_length":524288,
"blob_type":"BlockBlob",
"url":"https://oc2d2817345i60006.blob.core.windows.net/oc2d2817345i200097container/oc2d2817345i20002296blob",
"sequencer":"00000000000004420000000000028963",
"storage_diagnostics":{"batchId":"b68529f3-68cd-4744-baa4-3c0498ec19f0"}
}
assert event.specversion == "1.0"
lmazuel marked this conversation as resolved.
Show resolved Hide resolved
assert event.time.__class__ == datetime
assert event.time.month == 8
assert event.time.day == 7
assert event.time.hour == 1
assert event.__class__ == CloudEvent


def test_cloud_custom_dict_with_extensions():
cloud_custom_dict_with_extensions = {
"id":"de0fd76c-4ef4-4dfb-ab3a-8f24a307e033",
"source":"https://egtest.dev/cloudcustomevent",
"data":{"team": "event grid squad"},
"type":"Azure.Sdk.Sample",
"time":"2020-08-07T02:06:08.11969Z",
"specversion":"1.0",
"ext1": "example",
"ext2": "example2"
}
event = CloudEvent.from_dict(cloud_custom_dict_with_extensions)
assert event.data == {"team": "event grid squad"}
assert event.__class__ == CloudEvent
assert event.extensions == {"ext1": "example", "ext2": "example2"}

def test_cloud_custom_dict_base64():
cloud_custom_dict_base64 = {
"id":"de0fd76c-4ef4-4dfb-ab3a-8f24a307e033",
"source":"https://egtest.dev/cloudcustomevent",
"data_base64":'Y2xvdWRldmVudA==',
"type":"Azure.Sdk.Sample",
"time":"2020-08-07T02:06:08.11969Z",
"specversion":"1.0"
}
event = CloudEvent.from_dict(cloud_custom_dict_base64)
assert event.data == b'cloudevent'
assert event.specversion == "1.0"
assert event.__class__ == CloudEvent

def test_data_and_base64_both_exist_raises():
with pytest.raises(ValueError):
CloudEvent.from_dict(
{"source":'sample',
"type":'type',
"data":'data',
"data_base64":'Y2kQ=='
}
)

def test_cloud_event_repr():
event = CloudEvent(
source='Azure.Core.Sample',
type='SampleType',
data='cloudevent'
)

assert repr(event).startswith("CloudEvent(source=Azure.Core.Sample, type=SampleType, specversion=1.0,")

def test_extensions_upper_case_value_error():
with pytest.raises(ValueError):
event = CloudEvent(
source='sample',
type='type',
data='data',
extensions={"lowercase123": "accepted", "NOTlower123": "not allowed"}
)

def test_extensions_not_alphanumeric_value_error():
with pytest.raises(ValueError):
event = CloudEvent(
source='sample',
type='type',
data='data',
extensions={"lowercase123": "accepted", "not@lph@nu^^3ic": "not allowed"}
)

def test_cloud_from_dict_with_invalid_extensions():
cloud_custom_dict_with_extensions = {
"id":"de0fd76c-4ef4-4dfb-ab3a-8f24a307e033",
"source":"https://egtest.dev/cloudcustomevent",
"data":{"team": "event grid squad"},
"type":"Azure.Sdk.Sample",
"time":"2020-08-07T02:06:08.11969Z",
"specversion":"1.0",
"ext1": "example",
"BADext2": "example2"
}
with pytest.raises(ValueError):
event = CloudEvent.from_dict(cloud_custom_dict_with_extensions)