Skip to content

Commit 1f2b1ed

Browse files
Adding Cosmos DB support for storage (#68)
* Adding Cosmos DB storage * Removed unnecessary file * Added quick storage testing functionality for debugging and improved documentation * Simplified storage test setup and added unit test * Fixed legacy container support and added relevant tests * Revised AsyncStorageBase export/imports * Fixed final unit tests and refined documentation * Added microsoft-agent-cosmos to CI files * Minor fixes to storage tests * Moved files around * Fixed imports and formatted with black * Made key operation tests more data driven * Fixed minor issues in unit tests
1 parent 9df9e21 commit 1f2b1ed

16 files changed

Lines changed: 1257 additions & 54 deletions

File tree

.azdo/ci-pr.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ steps:
5151
python -m pip install ./dist/microsoft_agents_hosting_aiohttp*.whl
5252
python -m pip install ./dist/microsoft_agents_hosting_teams*.whl
5353
python -m pip install ./dist/microsoft_agents_storage_blob*.whl
54+
python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl
5455
displayName: 'Install wheels'
5556

5657
- script: |
5758
pytest
58-
displayName: 'Test with pytest'
59+
displayName: 'Test with pytest'

.github/workflows/python-package.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ jobs:
5757
python -m pip install ./dist/microsoft_agents_hosting_aiohttp*.whl
5858
python -m pip install ./dist/microsoft_agents_hosting_teams*.whl
5959
python -m pip install ./dist/microsoft_agents_storage_blob*.whl
60+
python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl
6061
- name: Test with pytest
6162
run: |
6263
pytest
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from .store_item import StoreItem
2-
from .storage import Storage
2+
from .storage import Storage, AsyncStorageBase
33
from .memory_storage import MemoryStorage
44

5-
__all__ = ["StoreItem", "Storage", "MemoryStorage"]
5+
__all__ = ["StoreItem", "Storage", "AsyncStorageBase", "MemoryStorage"]

libraries/microsoft-agents-hosting-core/microsoft/agents/hosting/core/storage/error_handling.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@
77
async def ignore_error(promise: Awaitable, ignore_error_filter: error_filter):
88
"""
99
Ignores errors based on the provided filter function.
10+
1011
promise: the awaitable to execute
1112
ignore_error_filter: a function that takes an Exception and returns True if the error should be
13+
ignored, False otherwise.
14+
15+
Returns the result of the promise if successful, or None if the error is ignored.
16+
Raises the error if it is not ignored.
1217
"""
1318
try:
1419
return await promise
@@ -21,6 +26,9 @@ async def ignore_error(promise: Awaitable, ignore_error_filter: error_filter):
2126
def is_status_code_error(*ignored_codes: list[int]) -> error_filter:
2227
"""
2328
Creates an error filter function that ignores errors with specific status codes.
29+
30+
ignored_codes: a list of status codes to ignore
31+
Returns a function that takes an Exception and returns True if the error's status code is in ignored_codes.
2432
"""
2533

2634
def func(err: Exception) -> bool:

libraries/microsoft-agents-hosting-core/microsoft/agents/hosting/core/storage/storage.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ async def delete(self, keys: list[str]) -> None:
4040

4141

4242
class AsyncStorageBase(Storage):
43-
"""Base class for asynchronous storage implementations."""
43+
"""Base class for asynchronous storage implementations with operations
44+
that work on single items. The bulk operations are implemented in terms
45+
of the single-item operations.
46+
"""
4447

4548
async def initialize(self) -> None:
4649
"""Initializes the storage container"""

libraries/microsoft-agents-hosting-core/microsoft/agents/hosting/core/storage/storage_test_utils.py

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -92,22 +92,6 @@ def subsets(lst, n=-1):
9292
return subsets
9393

9494

95-
class StorageMock(ABC):
96-
"""A mock wrapper around a Storage implementation to be used in tests."""
97-
98-
def get_backing_store(self) -> Storage:
99-
raise NotImplementedError("Subclasses must implement this")
100-
101-
async def read(self, *args, **kwargs):
102-
return await self.get_backing_store().read(*args, **kwargs)
103-
104-
async def write(self, *args, **kwargs):
105-
return await self.get_backing_store().write(*args, **kwargs)
106-
107-
async def delete(self, *args, **kwargs):
108-
return await self.get_backing_store().delete(*args, **kwargs)
109-
110-
11195
# bootstrapping class to compare against
11296
# if this class is correct, then the tests are correct
11397
class StorageBaseline(Storage):
@@ -133,10 +117,14 @@ def delete(self, keys: list[str]) -> None:
133117

134118
async def equals(self, other) -> bool:
135119
"""
136-
Compare the items for all keys seenby this mock instance.
120+
Compare the items for all keys seen by this mock instance.
121+
122+
Note:
137123
This is an extra safety measure, and I've made the
138124
executive decision to not test this method itself
139-
as it is not the main focus of the test suite.
125+
because passing tests with calls to this method
126+
is also dependent on the correctness of other
127+
aspects, based on the other assertions in the tests.
140128
"""
141129
for key in self._key_history:
142130
if key not in self._memory:
@@ -155,6 +143,7 @@ async def equals(self, other) -> bool:
155143

156144

157145
class StorageTestsCommon(ABC):
146+
"""Common fixtures for Storage implementations."""
158147

159148
KEY_LIST = [
160149
"f",
@@ -211,8 +200,16 @@ def changes(self, request):
211200

212201

213202
class CRUDStorageTests(StorageTestsCommon):
203+
"""Tests for Storage implementations that support CRUD operations.
204+
205+
To use, subclass and implement the `storage` method.
206+
"""
214207

215-
async def storage(self, initial_data=None, existing=False):
208+
async def storage(self, initial_data=None, existing=False) -> Storage:
209+
"""Return a Storage instance to be tested.
210+
:param initial_data: The initial data to populate the storage with.
211+
:param existing: If True, the storage instance should connect to an existing store.
212+
"""
216213
raise NotImplementedError("Subclasses must implement this")
217214

218215
@pytest.mark.asyncio
@@ -446,9 +443,64 @@ async def test_flow(self):
446443
await storage.read(["key_b"], target_cls=MockStoreItemB)
447444
assert await baseline_storage.equals(storage)
448445

449-
if not isinstance(storage.get_backing_store(), MemoryStorage):
446+
if not isinstance(storage, MemoryStorage):
450447
# if not memory storage, then items should persist
451448
del storage
452449
gc.collect()
453450
storage_alt = await self.storage(existing=True)
454451
assert await baseline_storage.equals(storage_alt)
452+
453+
454+
class QuickCRUDStorageTests(CRUDStorageTests):
455+
"""Reduced set of permutations for quicker tests. Useful for debugging."""
456+
457+
KEY_LIST = ["\\?/#\t\n\r*", "test.txt"]
458+
459+
READ_KEY_LIST = KEY_LIST + ["nonexistent_key"]
460+
461+
STATE_LIST = [
462+
{key: MockStoreItem({"id": key, "value": f"value{key}"}) for key in KEY_LIST}
463+
]
464+
465+
@pytest.fixture(params=STATE_LIST)
466+
def initial_state(self, request):
467+
return request.param
468+
469+
@pytest.fixture(params=KEY_LIST)
470+
def key(self, request):
471+
return request.param
472+
473+
@pytest.fixture(params=[KEY_LIST])
474+
def keys(self, request):
475+
return request.param
476+
477+
@pytest.fixture(params=subsets(KEY_LIST, 2))
478+
def changes(self, request):
479+
changes_obj = {}
480+
keys = request.param
481+
changes_obj["new_key"] = MockStoreItemB(
482+
{"field": "new_value_for_new_key"}, True
483+
)
484+
for i, key in enumerate(keys):
485+
if i % 2 == 0:
486+
changes_obj[key] = MockStoreItemB(
487+
{"data": f"value{key}"}, (i // 2) % 2 == 0
488+
)
489+
else:
490+
changes_obj[key] = MockStoreItem(
491+
{"id": key, "value": f"new_value_for_{key}"}
492+
)
493+
changes_obj["new_key_2"] = MockStoreItem({"field": "new_value_for_new_key_2"})
494+
return changes_obj
495+
496+
497+
def debug_print(*args):
498+
"""Print debug information clearly separated in the console."""
499+
print("\n" * 2)
500+
print("--- DEBUG ---")
501+
for arg in args:
502+
print("\n" * 2)
503+
print(arg)
504+
print("\n" * 2)
505+
print("--- ----- ---")
506+
print("\n" * 2)
Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,11 @@
11
from microsoft.agents.hosting.core.storage.memory_storage import MemoryStorage
2-
from microsoft.agents.hosting.core.storage.storage_test_utils import (
3-
CRUDStorageTests,
4-
StorageMock,
5-
)
2+
from microsoft.agents.hosting.core.storage.storage_test_utils import CRUDStorageTests
63

74

8-
class MemoryStorageMock(StorageMock):
9-
10-
def __init__(self, initial_data: dict = None):
11-
5+
class TestMemoryStorage(CRUDStorageTests):
6+
async def storage(self, initial_data=None):
127
data = {
138
key: value.store_item_to_json()
149
for key, value in (initial_data or {}).items()
1510
}
16-
self.storage = MemoryStorage(data)
17-
18-
def get_backing_store(self):
19-
return self.storage
20-
21-
22-
class TestMemoryStorage(CRUDStorageTests):
23-
24-
async def storage(self, initial_state=None):
25-
return MemoryStorageMock(initial_state)
11+
return MemoryStorage(data)

libraries/microsoft-agents-storage-blob/tests/test_blob_storage.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
from microsoft.agents.hosting.core.storage.storage_test_utils import (
1414
CRUDStorageTests,
15-
StorageMock,
1615
StorageBaseline,
1716
MockStoreItem,
1817
MockStoreItemB,
@@ -69,15 +68,6 @@ async def blob_storage():
6968
await container_client.delete_container()
7069

7170

72-
class BlobStorageMock(StorageMock):
73-
74-
def __init__(self, blob_storage):
75-
self.storage = blob_storage
76-
77-
def get_backing_store(self):
78-
return self.storage
79-
80-
8171
@pytest.mark.skipif(not EMULATOR_RUNNING, reason="Needs the emulator to run.")
8272
class TestBlobStorage(CRUDStorageTests):
8373

@@ -90,7 +80,7 @@ async def storage(self, initial_data=None, existing=False):
9080
value_rep = json.dumps(value.store_item_to_json())
9181
await container_client.upload_blob(name=key, data=value_rep, overwrite=True)
9282

93-
return BlobStorageMock(storage)
83+
return storage
9484

9585
@pytest.mark.asyncio
9686
async def test_initialize(self, blob_storage):
@@ -104,6 +94,26 @@ async def test_initialize(self, blob_storage):
10494
"key": MockStoreItem({"id": "item", "value": "data"})
10595
}
10696

97+
@pytest.mark.asyncio
98+
async def test_external_change_is_visible(self):
99+
blob_storage, container_client = await blob_storage_instance()
100+
assert (await blob_storage.read(["key"], target_cls=MockStoreItem)) == {}
101+
assert (await blob_storage.read(["key2"], target_cls=MockStoreItem)) == {}
102+
await container_client.upload_blob(
103+
name="key", data=json.dumps({"id": "item", "value": "data"}), overwrite=True
104+
)
105+
await container_client.upload_blob(
106+
name="key2",
107+
data=json.dumps({"id": "another_item", "value": "new_val"}),
108+
overwrite=True,
109+
)
110+
assert (await blob_storage.read(["key"], target_cls=MockStoreItem))[
111+
"key"
112+
] == MockStoreItem({"id": "item", "value": "data"})
113+
assert (await blob_storage.read(["key2"], target_cls=MockStoreItem))[
114+
"key2"
115+
] == MockStoreItem({"id": "another_item", "value": "new_val"})
116+
107117
@pytest.mark.asyncio
108118
async def test_blob_storage_flow_existing_container_and_persistence(self):
109119

@@ -183,5 +193,4 @@ async def test_blob_storage_flow_existing_container_and_persistence(self):
183193
== initial_data["1230"]
184194
)
185195

186-
# teardown
187196
await container_client.delete_container()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .cosmos_db_storage import CosmosDBStorage
2+
from .cosmos_db_storage_config import CosmosDBStorageConfig
3+
4+
__all__ = [
5+
"CosmosDBStorage",
6+
"CosmosDBStorageConfig",
7+
]

0 commit comments

Comments
 (0)