Skip to content

Commit 3bca4af

Browse files
committed
Add support for CosmosDB bindings
1 parent 350bac2 commit 3bca4af

File tree

27 files changed

+602
-26
lines changed

27 files changed

+602
-26
lines changed

.travis.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ addons:
1717

1818
packages:
1919
- dotnet-sdk-2.1.4
20+
- azure-functions-core-tools
2021

2122
cache:
2223
pip: true

appveyor.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ environment:
77
matrix:
88
- PYTHON: "C:\\Python36-x64\\python.exe"
99

10+
install:
11+
- choco install azure-functions-core-tools --pre
12+
1013
build_script:
1114
- "%PYTHON% -m pip install -U -e .[dev]"
1215
- "%PYTHON% setup.py webhost"

azure/functions/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from ._abc import HttpRequest, TimerRequest, InputStream, Context, Out # NoQA
2+
from ._cosmosdb import Document, DocumentList # NoQA
23
from ._http import HttpResponse # NoQA
34
from ._queue import QueueMessage # NoQA

azure/functions/_abc.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,31 @@ def time_next_visible(self) -> typing.Optional[datetime.datetime]:
197197
@abc.abstractmethod
198198
def pop_receipt(self) -> typing.Optional[str]:
199199
pass
200+
201+
202+
class Document(abc.ABC):
203+
@classmethod
204+
@abc.abstractmethod
205+
def from_json(cls, json_data: str) -> 'Document':
206+
pass
207+
208+
@classmethod
209+
@abc.abstractmethod
210+
def from_dict(cls, dct: dict) -> 'Document':
211+
pass
212+
213+
@abc.abstractmethod
214+
def __getitem__(self, key):
215+
pass
216+
217+
@abc.abstractmethod
218+
def __setitem__(self, key, value):
219+
pass
220+
221+
@abc.abstractmethod
222+
def to_json(self) -> str:
223+
pass
224+
225+
226+
class DocumentList(abc.ABC):
227+
pass

azure/functions/_cosmosdb.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import collections
2+
import json
3+
4+
from . import _abc
5+
6+
7+
# Internal properties of CosmosDB documents.
8+
_SYSTEM_KEYS = {'_etag', '_lsn', '_rid', '_self', '_ts'}
9+
10+
11+
class Document(_abc.Document, collections.UserDict):
12+
"""An Azure Document.
13+
14+
Document objects are ``UserDict`` subclasses and behave like dicts.
15+
"""
16+
17+
@classmethod
18+
def from_json(cls, json_data: str) -> 'Document':
19+
"""Create a Document from a JSON string."""
20+
return cls.from_dict(json.loads(json_data))
21+
22+
@classmethod
23+
def from_dict(cls, dct: dict) -> 'Document':
24+
"""Create a Document from a dict object."""
25+
filtered = {k: v for k, v in dct.items() if k not in _SYSTEM_KEYS}
26+
return cls(filtered) # type: ignore
27+
28+
def to_json(self) -> str:
29+
"""Return the JSON representation of the document."""
30+
return json.dumps(dict(self))
31+
32+
def __getitem__(self, key):
33+
return collections.UserDict.__getitem__(self, key)
34+
35+
def __setitem__(self, key, value):
36+
return collections.UserDict.__setitem__(self, key, value)
37+
38+
def __repr__(self) -> str:
39+
return (
40+
f'<azure.Document at 0x{id(self):0x}>'
41+
)
42+
43+
44+
class DocumentList(_abc.DocumentList, collections.UserList):
45+
"A ``UserList`` subclass containing a list of :class:`~Document` objects"
46+
pass

azure/worker/bindings/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
# Import type implementations and converters
99
# to get them registered and available:
1010
from . import blob # NoQA
11+
from . import cosmosdb # NoQA
1112
from . import http # NoQA
1213
from . import queue # NoQA
1314
from . import timer # NoQA

azure/worker/bindings/cosmosdb.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import collections.abc
2+
import json
3+
import typing
4+
5+
from azure.functions import _cosmosdb as cdb
6+
7+
from . import meta
8+
from .. import protos
9+
10+
11+
class CosmosDBConverter(meta.InConverter, meta.OutConverter,
12+
binding='cosmosDB'):
13+
14+
@classmethod
15+
def check_input_type_annotation(cls, pytype: type) -> bool:
16+
return issubclass(pytype, cdb.DocumentList)
17+
18+
@classmethod
19+
def check_output_type_annotation(cls, pytype: type) -> bool:
20+
return issubclass(pytype, (cdb.DocumentList, cdb.Document))
21+
22+
@classmethod
23+
def from_proto(cls, data: protos.TypedData, *,
24+
pytype: typing.Optional[type],
25+
trigger_metadata) -> cdb.DocumentList:
26+
data_type = data.WhichOneof('data')
27+
28+
if data_type == 'string':
29+
body = data.string
30+
31+
elif data_type == 'bytes':
32+
body = data.bytes.decode('utf-8')
33+
34+
elif data_type == 'json':
35+
body = data.json
36+
37+
else:
38+
raise NotImplementedError(
39+
f'unsupported queue payload type: {data_type}')
40+
41+
documents = json.loads(body)
42+
if not isinstance(documents, list):
43+
documents = [documents]
44+
45+
return cdb.DocumentList(
46+
cdb.Document.from_dict(doc) for doc in documents)
47+
48+
@classmethod
49+
def to_proto(cls, obj: typing.Any, *,
50+
pytype: typing.Optional[type]) -> protos.TypedData:
51+
if isinstance(obj, cdb.Document):
52+
data = cdb.DocumentList([obj])
53+
54+
elif isinstance(obj, cdb.DocumentList):
55+
data = obj
56+
57+
elif isinstance(obj, collections.abc.Iterable):
58+
data = cdb.DocumentList()
59+
60+
for doc in obj:
61+
if not isinstance(doc, cdb.Document):
62+
raise NotImplementedError
63+
else:
64+
data.append(doc)
65+
66+
else:
67+
raise NotImplementedError
68+
69+
return protos.TypedData(
70+
json=json.dumps([dict(d) for d in data])
71+
)
72+
73+
74+
class CosmosDBTriggerConverter(CosmosDBConverter,
75+
binding='cosmosDBTrigger', trigger=True):
76+
pass

azure/worker/bindings/meta.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import abc
2+
import datetime
23
import enum
34
import json
45
import typing
@@ -131,6 +132,22 @@ def _decode_trigger_metadata_field(
131132
data, python_type=python_type,
132133
context=f'field {field!r} in trigger metadata')
133134

135+
@classmethod
136+
def _parse_datetime_metadata(
137+
cls, trigger_metadata: typing.Mapping[str, protos.TypedData],
138+
field: str) -> typing.Optional[datetime.datetime]:
139+
140+
datetime_str = cls._decode_trigger_metadata_field(
141+
trigger_metadata, field, python_type=str)
142+
143+
if datetime_str is None:
144+
return None
145+
else:
146+
# UTC ISO 8601 assumed
147+
dt = datetime.datetime.strptime(
148+
datetime_str, '%Y-%m-%dT%H:%M:%S+00:00')
149+
return dt.replace(tzinfo=datetime.timezone.utc)
150+
134151

135152
class InConverter(_BaseConverter, binding=None):
136153

azure/worker/bindings/queue.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -94,22 +94,6 @@ def from_proto(cls, data: protos.TypedData, *,
9494
trigger_metadata, 'PopReceipt', python_type=str)
9595
)
9696

97-
@classmethod
98-
def _parse_datetime_metadata(
99-
cls, trigger_metadata: typing.Mapping[str, protos.TypedData],
100-
field: str) -> typing.Optional[datetime.datetime]:
101-
102-
datetime_str = cls._decode_trigger_metadata_field(
103-
trigger_metadata, field, python_type=str)
104-
105-
if datetime_str is None:
106-
return None
107-
else:
108-
# UTC ISO 8601 assumed
109-
dt = datetime.datetime.strptime(
110-
datetime_str, '%Y-%m-%dT%H:%M:%S+00:00')
111-
return dt.replace(tzinfo=datetime.timezone.utc)
112-
11397

11498
class QueueMessageOutConverter(meta.OutConverter, binding='queue'):
11599

azure/worker/testutils.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
TESTS_ROOT = PROJECT_ROOT / 'tests'
3737
DEFAULT_WEBHOST_DLL_PATH = PROJECT_ROOT / 'build' / 'webhost' / \
3838
'Microsoft.Azure.WebJobs.Script.WebHost.dll'
39+
EXTENSIONS_PATH = PROJECT_ROOT / 'build' / 'extensions' / 'bin'
3940
FUNCS_PATH = TESTS_ROOT / 'http_functions'
4041
WORKER_PATH = pathlib.Path(__file__).parent.parent.parent
4142
WORKER_CONFIG = WORKER_PATH / '.testconfig'
@@ -94,7 +95,7 @@ def get_script_dir(cls):
9495

9596
@classmethod
9697
def setUpClass(cls):
97-
script_dir = cls.get_script_dir()
98+
script_dir = pathlib.Path(cls.get_script_dir())
9899
if os.environ.get('PYAZURE_WEBHOST_DEBUG'):
99100
cls.host_stdout = None
100101
else:
@@ -103,6 +104,17 @@ def setUpClass(cls):
103104
cls.webhost = start_webhost(script_dir=script_dir,
104105
stdout=cls.host_stdout)
105106

107+
extensions = TESTS_ROOT / script_dir / 'bin'
108+
109+
if not extensions.exists():
110+
if extensions.is_symlink():
111+
extensions.unlink()
112+
113+
extensions.symlink_to(EXTENSIONS_PATH, target_is_directory=True)
114+
cls.linked_extensions = True
115+
else:
116+
cls.linked_extensions = False
117+
106118
@classmethod
107119
def tearDownClass(cls):
108120
cls.webhost.close()
@@ -112,6 +124,10 @@ def tearDownClass(cls):
112124
cls.host_stdout.close()
113125
cls.host_stdout = None
114126

127+
if cls.linked_extensions:
128+
extensions = TESTS_ROOT / cls.get_script_dir() / 'bin'
129+
extensions.unlink()
130+
115131
def _run_test(self, test, *args, **kwargs):
116132
if self.host_stdout is None:
117133
test(self, *args, **kwargs)
@@ -475,6 +491,10 @@ def popen_webhost(*, stdout, stderr, script_root=FUNCS_PATH, port=None):
475491
if st:
476492
extra_env['AzureWebJobsStorage'] = st
477493

494+
cosmos = testconfig['azure'].get('cosmosdb_key')
495+
if cosmos:
496+
extra_env['AzureWebJobsCosmosDBConnectionString'] = cosmos
497+
478498
if port is not None:
479499
extra_env['ASPNETCORE_URLS'] = f'http://*:{port}'
480500

0 commit comments

Comments
 (0)