From 8029318adfa2654c8133da607b12994703d86592 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 9 Oct 2025 07:43:08 -0700 Subject: [PATCH 1/9] Reorganizing storage modules --- .../hosting/core/storage/__init__.py | 8 ++-- .../core/storage/_transcript/__init__.py | 21 +++++++++ .../transcript_file_store.py | 46 ++++--------------- .../{ => _transcript}/transcript_info.py | 0 .../{ => _transcript}/transcript_logger.py | 8 ++-- .../transcript_memory_store.py | 10 ++-- .../{ => _transcript}/transcript_store.py | 11 +++-- .../core/storage/_wrappers/__init__.py | 0 8 files changed, 49 insertions(+), 55 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/{ => _transcript}/transcript_file_store.py (87%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/{ => _transcript}/transcript_info.py (100%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/{ => _transcript}/transcript_logger.py (97%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/{ => _transcript}/transcript_memory_store.py (97%) rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/{ => _transcript}/transcript_store.py (88%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py index 21c334cb..98a84b33 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py @@ -1,16 +1,16 @@ from .store_item import StoreItem from .storage import Storage, AsyncStorageBase from .memory_storage import MemoryStorage -from .transcript_info import TranscriptInfo -from .transcript_logger import ( +from ._transcript import ( + TranscriptInfo, TranscriptLogger, ConsoleTranscriptLogger, TranscriptLoggerMiddleware, FileTranscriptLogger, PagedResult, + TranscriptStore, + FileTranscriptStore ) -from .transcript_store import TranscriptStore -from .transcript_file_store import FileTranscriptStore __all__ = [ "StoreItem", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py new file mode 100644 index 00000000..d271d641 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py @@ -0,0 +1,21 @@ +from .transcript_info import TranscriptInfo +from .transcript_logger import ( + TranscriptLogger, + ConsoleTranscriptLogger, + TranscriptLoggerMiddleware, + FileTranscriptLogger, + PagedResult, +) +from .transcript_store import TranscriptStore +from .transcript_file_store import FileTranscriptStore + +__all__ = [ + "TranscriptInfo", + "TranscriptLogger", + "ConsoleTranscriptLogger", + "TranscriptLoggerMiddleware", + "FileTranscriptLogger", + "PagedResult", + "TranscriptStore", + "FileTranscriptStore", +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_file_store.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_file_store.py similarity index 87% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_file_store.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_file_store.py index 7f9d37ad..665897a5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_file_store.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_file_store.py @@ -4,20 +4,19 @@ from __future__ import annotations import asyncio -import json import os import re from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Tuple, Union +from typing import Any, Optional, Union + +from microsoft_agents.activity import Activity # type: ignore from .transcript_logger import TranscriptLogger from .transcript_logger import PagedResult from .transcript_info import TranscriptInfo -from microsoft_agents.activity import Activity # type: ignore - class FileTranscriptStore(TranscriptLogger): """ @@ -87,10 +86,10 @@ async def list_transcripts(self, channel_id: str) -> PagedResult[TranscriptInfo] :param channel_id: The channel ID to list transcripts for.""" channel_dir = self._channel_dir(channel_id) - def _list() -> List[TranscriptInfo]: + def _list() -> list[TranscriptInfo]: if not channel_dir.exists(): return [] - results: List[TranscriptInfo] = [] + results: list[TranscriptInfo] = [] for p in channel_dir.glob("*.transcript"): # mtime is a reasonable proxy for 'created/updated' created = datetime.fromtimestamp(p.stat().st_mtime, tz=timezone.utc) @@ -127,12 +126,12 @@ async def get_transcript_activities( """ file_path = self._file_path(channel_id, conversation_id) - def _read_page() -> Tuple[List[Activity], Optional[str]]: + def _read_page() -> tuple[list[Activity], Optional[str]]: if not file_path.exists(): return [], None offset = int(continuation_token) if continuation_token else 0 - results: List[Activity] = [] + results: list[Activity] = [] with open(file_path, "rb") as f: f.seek(0, os.SEEK_END) @@ -215,7 +214,7 @@ def _sanitize(pattern: re.Pattern[str], value: str) -> str: return value or "unknown" -def _get_ids(activity: Activity) -> Tuple[str, str]: +def _get_ids(activity: Activity) -> tuple[str, str]: # Works with both dict-like and object-like Activity def _get(obj: Any, *path: str) -> Optional[Any]: cur = obj @@ -234,34 +233,5 @@ def _get(obj: Any, *path: str) -> Optional[Any]: raise ValueError("Activity must include channel_id and conversation.id") return str(channel_id), str(conversation_id) - -def _to_plain_dict(activity: Activity) -> Dict[str, Any]: - - if isinstance(activity, dict): - return activity - # Best-effort conversion for dataclass/attr/objects - try: - import dataclasses - - if dataclasses.is_dataclass(activity): - return dataclasses.asdict(activity) # type: ignore[arg-type] - except Exception: - pass - try: - return json.loads( - json.dumps(activity, default=lambda o: getattr(o, "__dict__", str(o))) - ) - except Exception: - # Fallback: minimal projection - channel_id, conversation_id = _get_ids(activity) - return { - "type": getattr(activity, "type", "message"), - "id": getattr(activity, "id", None), - "channel_id": channel_id, - "conversation": {"id": conversation_id}, - "text": getattr(activity, "text", None), - } - - def _utc_iso_now() -> str: return datetime.now(timezone.utc).isoformat() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_info.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_info.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_info.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_info.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_logger.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_logger.py similarity index 97% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_logger.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_logger.py index 72d0c5ef..ab47163a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_logger.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_logger.py @@ -5,11 +5,10 @@ import string import json -from typing import Any, Optional from abc import ABC, abstractmethod from datetime import datetime, timezone from queue import Queue -from typing import Awaitable, Callable, List, Optional +from typing import Awaitable, Callable, Optional, Generic, TypeVar from dataclasses import dataclass from microsoft_agents.activity import Activity, ChannelAccount @@ -17,7 +16,6 @@ from microsoft_agents.activity.activity_types import ActivityTypes from microsoft_agents.activity.conversation_reference import ActivityEventNames from microsoft_agents.hosting.core.middleware_set import Middleware, TurnContext -from typing import Generic, TypeVar T = TypeVar("T") @@ -25,7 +23,7 @@ @dataclass class PagedResult(Generic[T]): - items: List[T] + items: list[T] continuation_token: Optional[str] = None @@ -140,7 +138,7 @@ async def on_turn( # pylint: disable=unused-argument async def send_activities_handler( ctx: TurnContext, - activities: List[Activity], + activities: list[Activity], next_send: Callable[[], Awaitable[None]], ): # Run full pipeline diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_memory_store.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_memory_store.py similarity index 97% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_memory_store.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_memory_store.py index 6bc170f1..7cdf416e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_memory_store.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_memory_store.py @@ -3,10 +3,12 @@ from threading import Lock from datetime import datetime, timezone -from typing import List +from typing import Optional + +from microsoft_agents.activity import Activity + from .transcript_logger import TranscriptLogger, PagedResult from .transcript_info import TranscriptInfo -from microsoft_agents.activity import Activity class TranscriptMemoryStore(TranscriptLogger): @@ -50,7 +52,7 @@ async def get_transcript_activities( self, channel_id: str, conversation_id: str, - continuation_token: str = None, + continuation_token: Optional[str] = None, start_date: datetime = datetime.min.replace(tzinfo=timezone.utc), ) -> PagedResult[Activity]: """ @@ -127,7 +129,7 @@ async def delete_transcript(self, channel_id: str, conversation_id: str) -> None ] async def list_transcripts( - self, channel_id: str, continuation_token: str = None + self, channel_id: str, continuation_token: Optional[str] = None ) -> PagedResult[TranscriptInfo]: """ Lists all transcripts (unique conversation IDs) for a given channel. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_store.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_store.py similarity index 88% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_store.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_store.py index 8e660bf8..28a7a673 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_store.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_store.py @@ -1,9 +1,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from abc import ABC, abstractmethod +from abc import abstractmethod from datetime import datetime, timezone +from typing import Optional + from microsoft_agents.activity import Activity + from .transcript_info import TranscriptInfo from .transcript_logger import TranscriptLogger @@ -14,7 +17,7 @@ async def get_transcript_activities( self, channel_id: str, conversation_id: str, - continuation_token: str = None, + continuation_token: Optional[str] = None, start_date: datetime = datetime.min.replace(tzinfo=timezone.utc), ) -> tuple[list[Activity], str]: """ @@ -30,8 +33,8 @@ async def get_transcript_activities( @abstractmethod async def list_transcripts( - self, channel_id: str, continuation_token: str = None - ) -> tuple[list[TranscriptInfo, str]]: + self, channel_id: str, continuation_token: Optional[str] = None + ) -> tuple[list[TranscriptInfo], Optional[str]]: """ Asynchronously lists transcripts for a given channel. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py new file mode 100644 index 00000000..e69de29b From 3426524c49f14c8b644ab6ea39fdeae2d5f707a0 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 9 Oct 2025 08:05:15 -0700 Subject: [PATCH 2/9] Adding storage wrappers --- .../hosting/core/storage/__init__.py | 4 +- .../core/storage/_wrappers/__init__.py | 10 +++ .../core/storage/_wrappers/_cached_storage.py | 56 ++++++++++++++++ .../core/storage/_wrappers/_item_storage.py | 26 ++++++++ .../storage/_wrappers/_storage_namespace.py | 66 +++++++++++++++++++ .../hosting/core/storage/storage.py | 16 ++--- .../storage/blob/blob_storage.py | 4 +- .../storage/cosmos/cosmos_db_storage.py | 4 +- 8 files changed, 172 insertions(+), 14 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py index 98a84b33..74f40db3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py @@ -1,5 +1,5 @@ from .store_item import StoreItem -from .storage import Storage, AsyncStorageBase +from .storage import Storage, _AsyncStorageBase from .memory_storage import MemoryStorage from ._transcript import ( TranscriptInfo, @@ -15,7 +15,7 @@ __all__ = [ "StoreItem", "Storage", - "AsyncStorageBase", + "_AsyncStorageBase", "MemoryStorage", "TranscriptInfo", "TranscriptLogger", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py index e69de29b..90adbccc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py @@ -0,0 +1,10 @@ +from ._cached_storage import _CachedStorage, _ClearableCachedStorage +from ._item_storage import _ItemStorage +from ._storage_namespace import _StorageNamespace + +__all__ = [ + "_CachedStorage", + "_ClearableCachedStorage", + "_ItemStorage", + "_StorageNamespace" +] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py new file mode 100644 index 00000000..b1470eba --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py @@ -0,0 +1,56 @@ +from typing import TypeVar, Generic + +from strenum import StrEnum + +from ..store_item import StoreItem +from ..storage import Storage + +StoreItemT = TypeVar("StoreItemT", bound=StoreItem) + +class _CachedStorage(Storage): + """Wrapper around Storage that adds a caching layer.""" + + def __init__(self, storage: Storage, cache: Storage): + """Initialize CachedStorage with a storage and cache. + + Args: + storage: The backing storage. + cache: The caching storage. This should ideally be faster or at least + used by fewer clients than the backing storage. + """ + self._storage = storage + self._cache = cache + + async def read( + self, keys: list[str], *, target_cls: type[StoreItemT] = None, **kwargs + ) -> dict[str, StoreItemT]: + + data = await self._cache.read(keys, target_cls=target_cls, **kwargs) + if len(data.keys()) < len(keys): + missing_keys = [k for k in keys if k not in data] + storage_data = await self._storage.read( + missing_keys, target_cls=target_cls, **kwargs + ) + if storage_data: + await self._cache.write(storage_data) + data.update(storage_data) + + return data + + async def write(self, changes: dict[str, StoreItemT]) -> None: + await self._cache.write(changes) + await self._storage.write(changes) + + async def delete(self, keys: list[str]) -> None: + await self._cache.delete(keys) + await self._storage.delete(keys) + +StorageT = TypeVar("StorageT", bound=Storage) +class _ClearableCachedStorage(_CachedStorage): + + def __init__(self, storage: Storage, cache_cls: type[Storage]): + super().__init__(storage, cache_cls()) + self._cache_cls = cache_cls + + async def clear_cache(self): + self._cache = self._cache_cls() \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py new file mode 100644 index 00000000..00c7faa0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py @@ -0,0 +1,26 @@ +from typing import Generic, TypeVar, Union + +from microsoft_agents.hosting.core import ( + Storage, + StoreItem +) + +StoreItemT = TypeVar("StoreItemT", bound=StoreItem) + +class _ItemStorage(Generic[StoreItemT]): + + def __init__(self, storage: Storage): + self._storage = storage + + async def read(self, keys: Union[str, list[str]], **kwargs) -> dict[str, StoreItemT]: + if isinstance(keys, str): + keys = [keys] + return await self._storage.read(keys, target_cls=StoreItemT, **kwargs) + + async def write(self, changes: dict[str, StoreItemT]) -> None: + await self._storage.write(changes) + + async def delete(self, keys: Union[str, list[str]]) -> None: + if isinstance(keys, str): + keys = [keys] + await self._storage.delete(keys) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py new file mode 100644 index 00000000..43eb47bc --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py @@ -0,0 +1,66 @@ +from typing import TypeVar + +from microsoft_agents.activity._error_handling import _raise_if_falsey + +from ..storage import Storage +from ..store_item import StoreItem + +StoreItemT = TypeVar("StoreItemT", bound=StoreItem) + +class _StorageNamespace(Storage): + """Wrapper around Storage that manages sign-in state specific to each user and channel. + + Uses the activity's channel_id and from.id to create a key prefix for storage operations. + """ + + def __init__( + self, + namespace: str, + storage: Storage, + ): + """ + Args: + channel_id: used to create the prefix + user_id: used to create the prefix + storage: the backing storage + cache_class: the cache class to use (defaults to DummyCache, which performs no caching). + This cache's lifetime is tied to the FlowStorageClient instance. + """ + + _raise_if_falsey("StorageNamespace.__init__()", namespace=namespace, storage=storage) + + if not namespace: + raise ValueError( + "StorageNamespace.__init__(): namespace must be set." + ) + + self._base_key = namespace + self._storage = storage + + @property + def base_key(self) -> str: + """Returns the prefix used for flow state storage isolation.""" + return self._base_key + + def key(self, vkey: str) -> str: + """Creates a storage key for a specific sign-in handler.""" + return f"{self._base_key}:{vkey}" + + async def read( + self, keys: list[str], *, target_cls: type[StoreItemT] = None, **kwargs + ) -> dict[str, StoreItemT]: + keys = [self.key(k) for k in keys] + res = await self._storage.read(keys, target_cls=target_cls, **kwargs) + virtual_res = {} + for key in res.keys(): + vkey_start = key.index(":") + 1 + virtual_res[key[vkey_start:]] = res[key] + return virtual_res + + async def write(self, changes: dict[str, StoreItemT]) -> None: + changes = {self.key(k): v for k, v in changes.items()} + await self._storage.write(changes) + + async def delete(self, keys: list[str]) -> None: + keys = [self.key(k) for k in keys] + await self._storage.delete(keys) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py index 6fd56037..c696de24 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage.py @@ -24,13 +24,13 @@ async def read( missing keys are omitted from the result. """ - pass + raise NotImplementedError() async def write(self, changes: dict[str, StoreItemT]) -> None: """Writes multiple items to storage. changes: A dictionary of key to StoreItem to write.""" - pass + raise NotImplementedError() async def delete(self, keys: list[str]) -> None: """Deletes multiple items from storage. @@ -39,10 +39,10 @@ async def delete(self, keys: list[str]) -> None: keys: A list of keys to delete. """ - pass + raise NotImplementedError() -class AsyncStorageBase(Storage): +class _AsyncStorageBase(Storage): """Base class for asynchronous storage implementations with operations that work on single items. The bulk operations are implemented in terms of the single-item operations. @@ -50,7 +50,7 @@ class AsyncStorageBase(Storage): async def initialize(self) -> None: """Initializes the storage container""" - pass + raise NotImplementedError() @abstractmethod async def _read_item( @@ -60,7 +60,7 @@ async def _read_item( Returns a tuple of (key, StoreItem) if found, or (None, None) if not found. """ - pass + raise NotImplementedError() async def read( self, keys: list[str], *, target_cls: Type[StoreItemT] = None, **kwargs @@ -80,7 +80,7 @@ async def read( @abstractmethod async def _write_item(self, key: str, value: StoreItemT) -> None: """Writes a single item to storage by key.""" - pass + raise NotImplementedError() async def write(self, changes: dict[str, StoreItemT]) -> None: if not changes: @@ -93,7 +93,7 @@ async def write(self, changes: dict[str, StoreItemT]) -> None: @abstractmethod async def _delete_item(self, key: str) -> None: """Deletes a single item from storage by key.""" - pass + raise NotImplementedError() async def delete(self, keys: list[str]) -> None: if not keys: diff --git a/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage.py b/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage.py index 2a7634b7..8ea77f68 100644 --- a/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage.py +++ b/libraries/microsoft-agents-storage-blob/microsoft_agents/storage/blob/blob_storage.py @@ -8,7 +8,7 @@ ) from microsoft_agents.hosting.core.storage import StoreItem -from microsoft_agents.hosting.core.storage.storage import AsyncStorageBase +from microsoft_agents.hosting.core.storage.storage import _AsyncStorageBase from microsoft_agents.hosting.core.storage._type_aliases import JSON from microsoft_agents.hosting.core.storage.error_handling import ( ignore_error, @@ -20,7 +20,7 @@ StoreItemT = TypeVar("StoreItemT", bound=StoreItem) -class BlobStorage(AsyncStorageBase): +class BlobStorage(_AsyncStorageBase): def __init__(self, config: BlobStorageConfig): diff --git a/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py b/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py index 16c3246c..c84ca8db 100644 --- a/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py +++ b/libraries/microsoft-agents-storage-cosmos/microsoft_agents/storage/cosmos/cosmos_db_storage.py @@ -17,7 +17,7 @@ import azure.cosmos.exceptions as cosmos_exceptions from azure.cosmos.partition_key import NonePartitionKeyValue -from microsoft_agents.hosting.core.storage import AsyncStorageBase, StoreItem +from microsoft_agents.hosting.core.storage import _AsyncStorageBase, StoreItem from microsoft_agents.hosting.core.storage._type_aliases import JSON from microsoft_agents.hosting.core.storage.error_handling import ignore_error @@ -31,7 +31,7 @@ ) -class CosmosDBStorage(AsyncStorageBase): +class CosmosDBStorage(_AsyncStorageBase): """A CosmosDB based storage provider using partitioning""" def __init__(self, config: CosmosDBStorageConfig): From 089ff32daca13e53d7e77bb39b430f0fc55cc4ec Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 13 Oct 2025 13:31:14 -0700 Subject: [PATCH 3/9] Implementing MemoryCache --- .../core/storage/_wrappers/_cached_storage.py | 12 +---- .../core/storage/_wrappers/_storage_cache.py | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_cache.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py index b1470eba..7fab4596 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py @@ -43,14 +43,4 @@ async def write(self, changes: dict[str, StoreItemT]) -> None: async def delete(self, keys: list[str]) -> None: await self._cache.delete(keys) - await self._storage.delete(keys) - -StorageT = TypeVar("StorageT", bound=Storage) -class _ClearableCachedStorage(_CachedStorage): - - def __init__(self, storage: Storage, cache_cls: type[Storage]): - super().__init__(storage, cache_cls()) - self._cache_cls = cache_cls - - async def clear_cache(self): - self._cache = self._cache_cls() \ No newline at end of file + await self._storage.delete(keys) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_cache.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_cache.py new file mode 100644 index 00000000..add5d71f --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_cache.py @@ -0,0 +1,44 @@ +from datetime import datetime, timezone +from typing import TypeVar + +from ..memory_storage import MemoryStorage +from ..store_item import StoreItem + +StoreItemT = TypeVar("StoreItemT", bound=StoreItem) + +class _MemoryCache(MemoryStorage): + def __init__(self, clear_interval: int = 300): + """In-memory cache that clears itself every `clear_interval` seconds. + + :param clear_interval: Time in seconds between automatic cache clears. + Defaults to 5 minutes. + :type clear_interval: int + :raises ValueError: If `clear_interval` is not positive. + """ + if clear_interval <= 0: + raise ValueError("clear_interval must be a positive integer.") + self._clear_interval = clear_interval + self._last_cleared = datetime.now(timezone.utc).timestamp() + + def clear(self): + """Clears the cache if the clear interval has passed.""" + with self._lock: + now = datetime.now(timezone.utc).timestamp() + if now - self._last_cleared > self._clear_interval: + self._memory.clear() + self._last_cleared = now + + async def read( + self, keys: list[str], *, target_cls: StoreItemT = None, **kwargs + ) -> dict[str, StoreItemT]: + self.clear() + return await super().read(keys, target_cls=target_cls, **kwargs) + + + async def write(self, changes: dict[str, StoreItem]): + self.clear() + return await super().write(changes) + + async def delete(self, keys: list[str]): + self.clear() + return await super().delete(keys) From 2b746aa57fc90f2992ad5cac4d77ce8a9a21b05f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 13 Oct 2025 13:55:14 -0700 Subject: [PATCH 4/9] MemoryCache implementation --- .../activity/entity/entity.py | 4 +- .../core/_oauth/_flow_storage_client.py | 5 + .../hosting/core/storage/__init__.py | 2 +- .../core/storage/_transcript/__init__.py | 2 +- .../_transcript/transcript_file_store.py | 1 + .../core/storage/_wrappers/__init__.py | 4 +- .../core/storage/_wrappers/_cached_storage.py | 7 +- .../core/storage/_wrappers/_item_storage.py | 26 --- .../{_storage_cache.py => _memory_cache.py} | 18 +- .../storage/_wrappers/_storage_namespace.py | 17 +- .../hosting_core/storage/wrappers/__init__.py | 0 .../storage/wrappers/test_cached_storage.py | 0 .../storage/wrappers/test_memory_cache.py | 0 .../wrappers/test_storage_namespace.py | 178 ++++++++++++++++++ 14 files changed, 217 insertions(+), 47 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/{_storage_cache.py => _memory_cache.py} (83%) create mode 100644 tests/hosting_core/storage/wrappers/__init__.py create mode 100644 tests/hosting_core/storage/wrappers/test_cached_storage.py create mode 100644 tests/hosting_core/storage/wrappers/test_memory_cache.py create mode 100644 tests/hosting_core/storage/wrappers/test_storage_namespace.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index e7352609..74b35142 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -1,14 +1,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Any, Optional -from enum import Enum +from typing import Any from pydantic import model_serializer, model_validator from pydantic.alias_generators import to_camel, to_snake from ..agents_model import AgentsModel, ConfigDict -from .._type_aliases import NonEmptyString class Entity(AgentsModel): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py index 867b3aa6..98fec631 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py @@ -19,6 +19,11 @@ async def delete(self, keys: list[str]) -> None: pass +# class FlowStorageClient(StorageNamespace): +# def __init__(self, channel_id: str, user_id: str, storage: Storage): +# super().__init__(_FlowStorageClient(channel_id, user_id, storage)) + + # this could be generalized. Ideas: # - CachedStorage class for two-tier storage # - Namespaced/PrefixedStorage class for namespacing keying diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py index 74f40db3..ef24fb75 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py @@ -9,7 +9,7 @@ FileTranscriptLogger, PagedResult, TranscriptStore, - FileTranscriptStore + FileTranscriptStore, ) __all__ = [ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py index d271d641..2ccb3b4b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py @@ -18,4 +18,4 @@ "PagedResult", "TranscriptStore", "FileTranscriptStore", -] \ No newline at end of file +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_file_store.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_file_store.py index 665897a5..6224c926 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_file_store.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/transcript_file_store.py @@ -233,5 +233,6 @@ def _get(obj: Any, *path: str) -> Optional[Any]: raise ValueError("Activity must include channel_id and conversation.id") return str(channel_id), str(conversation_id) + def _utc_iso_now() -> str: return datetime.now(timezone.utc).isoformat() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py index 90adbccc..c1624735 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py @@ -6,5 +6,5 @@ "_CachedStorage", "_ClearableCachedStorage", "_ItemStorage", - "_StorageNamespace" -] \ No newline at end of file + "_StorageNamespace", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py index 7fab4596..e6bcbb27 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py @@ -7,12 +7,13 @@ StoreItemT = TypeVar("StoreItemT", bound=StoreItem) + class _CachedStorage(Storage): """Wrapper around Storage that adds a caching layer.""" def __init__(self, storage: Storage, cache: Storage): """Initialize CachedStorage with a storage and cache. - + Args: storage: The backing storage. cache: The caching storage. This should ideally be faster or at least @@ -34,7 +35,7 @@ async def read( if storage_data: await self._cache.write(storage_data) data.update(storage_data) - + return data async def write(self, changes: dict[str, StoreItemT]) -> None: @@ -43,4 +44,4 @@ async def write(self, changes: dict[str, StoreItemT]) -> None: async def delete(self, keys: list[str]) -> None: await self._cache.delete(keys) - await self._storage.delete(keys) \ No newline at end of file + await self._storage.delete(keys) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py deleted file mode 100644 index 00c7faa0..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Generic, TypeVar, Union - -from microsoft_agents.hosting.core import ( - Storage, - StoreItem -) - -StoreItemT = TypeVar("StoreItemT", bound=StoreItem) - -class _ItemStorage(Generic[StoreItemT]): - - def __init__(self, storage: Storage): - self._storage = storage - - async def read(self, keys: Union[str, list[str]], **kwargs) -> dict[str, StoreItemT]: - if isinstance(keys, str): - keys = [keys] - return await self._storage.read(keys, target_cls=StoreItemT, **kwargs) - - async def write(self, changes: dict[str, StoreItemT]) -> None: - await self._storage.write(changes) - - async def delete(self, keys: Union[str, list[str]]) -> None: - if isinstance(keys, str): - keys = [keys] - await self._storage.delete(keys) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_cache.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_memory_cache.py similarity index 83% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_cache.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_memory_cache.py index add5d71f..e5e320fa 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_cache.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_memory_cache.py @@ -6,10 +6,23 @@ StoreItemT = TypeVar("StoreItemT", bound=StoreItem) + +class _DummyCache(Storage): + + async def read(self, keys: list[str], **kwargs) -> dict[str, _FlowState]: + return {} + + async def write(self, changes: dict[str, _FlowState]) -> None: + pass + + async def delete(self, keys: list[str]) -> None: + pass + + class _MemoryCache(MemoryStorage): def __init__(self, clear_interval: int = 300): """In-memory cache that clears itself every `clear_interval` seconds. - + :param clear_interval: Time in seconds between automatic cache clears. Defaults to 5 minutes. :type clear_interval: int @@ -20,7 +33,7 @@ def __init__(self, clear_interval: int = 300): self._clear_interval = clear_interval self._last_cleared = datetime.now(timezone.utc).timestamp() - def clear(self): + async def clear(self): """Clears the cache if the clear interval has passed.""" with self._lock: now = datetime.now(timezone.utc).timestamp() @@ -33,7 +46,6 @@ async def read( ) -> dict[str, StoreItemT]: self.clear() return await super().read(keys, target_cls=target_cls, **kwargs) - async def write(self, changes: dict[str, StoreItem]): self.clear() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py index 43eb47bc..21cfe943 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py @@ -7,6 +7,7 @@ StoreItemT = TypeVar("StoreItemT", bound=StoreItem) + class _StorageNamespace(Storage): """Wrapper around Storage that manages sign-in state specific to each user and channel. @@ -27,13 +28,13 @@ def __init__( This cache's lifetime is tied to the FlowStorageClient instance. """ - _raise_if_falsey("StorageNamespace.__init__()", namespace=namespace, storage=storage) + _raise_if_falsey( + "StorageNamespace.__init__()", namespace=namespace, storage=storage + ) if not namespace: - raise ValueError( - "StorageNamespace.__init__(): namespace must be set." - ) - + raise ValueError("StorageNamespace.__init__(): namespace must be set.") + self._base_key = namespace self._storage = storage @@ -41,11 +42,11 @@ def __init__( def base_key(self) -> str: """Returns the prefix used for flow state storage isolation.""" return self._base_key - + def key(self, vkey: str) -> str: """Creates a storage key for a specific sign-in handler.""" return f"{self._base_key}:{vkey}" - + async def read( self, keys: list[str], *, target_cls: type[StoreItemT] = None, **kwargs ) -> dict[str, StoreItemT]: @@ -63,4 +64,4 @@ async def write(self, changes: dict[str, StoreItemT]) -> None: async def delete(self, keys: list[str]) -> None: keys = [self.key(k) for k in keys] - await self._storage.delete(keys) \ No newline at end of file + await self._storage.delete(keys) diff --git a/tests/hosting_core/storage/wrappers/__init__.py b/tests/hosting_core/storage/wrappers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/storage/wrappers/test_cached_storage.py b/tests/hosting_core/storage/wrappers/test_cached_storage.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/storage/wrappers/test_memory_cache.py b/tests/hosting_core/storage/wrappers/test_memory_cache.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/storage/wrappers/test_storage_namespace.py b/tests/hosting_core/storage/wrappers/test_storage_namespace.py new file mode 100644 index 00000000..78b13261 --- /dev/null +++ b/tests/hosting_core/storage/wrappers/test_storage_namespace.py @@ -0,0 +1,178 @@ +import pytest + +from microsoft_agents.hosting.core.storage import MemoryStorage +from microsoft_agents.hosting.core._oauth import _FlowState, _FlowStorageClient + +from tests._common.storage.utils import MockStoreItem +from tests._common.data import TEST_DEFAULTS + +DEFAULTS = TEST_DEFAULTS() + + +class TestStorageNamespace: + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def client(self, storage): + return _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "channel_id, user_id", + [ + ("channel_id", "user_id"), + ("teams_id", "Bob"), + ("channel", "Alice"), + ], + ) + async def test_init_base_key(self, mocker, channel_id, user_id): + client = _FlowStorageClient(channel_id, user_id, mocker.Mock()) + assert client.base_key == f"auth/{channel_id}/{user_id}/" + + @pytest.mark.asyncio + async def test_init_fails_without_user_id(self, storage): + with pytest.raises(ValueError): + _FlowStorageClient(DEFAULTS.channel_id, "", storage) + + @pytest.mark.asyncio + async def test_init_fails_without_channel_id(self, storage): + with pytest.raises(ValueError): + _FlowStorageClient("", DEFAULTS.user_id, storage) + + @pytest.mark.parametrize( + "auth_handler_id, expected", + [ + ("handler", "auth/__channel_id/__user_id/handler"), + ("auth_handler", "auth/__channel_id/__user_id/auth_handler"), + ], + ) + def test_key(self, client, auth_handler_id, expected): + assert client.key(auth_handler_id) == expected + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_handler_id", ["handler", "auth_handler"]) + async def test_read(self, mocker, auth_handler_id): + storage = mocker.AsyncMock() + key = f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/{auth_handler_id}" + storage.read.return_value = {key: _FlowState()} + client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + res = await client.read(auth_handler_id) + assert res is storage.read.return_value[key] + storage.read.assert_called_once_with( + [client.key(auth_handler_id)], target_cls=_FlowState + ) + + @pytest.mark.asyncio + async def test_read_missing(self, mocker): + storage = mocker.AsyncMock() + storage.read.return_value = {} + client = _FlowStorageClient("__channel_id", "__user_id", storage) + res = await client.read("non_existent_handler") + assert res is None + storage.read.assert_called_once_with( + [client.key("non_existent_handler")], target_cls=_FlowState + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_handler_id", ["handler", "auth_handler"]) + async def test_write(self, mocker, auth_handler_id): + storage = mocker.AsyncMock() + storage.write.return_value = None + client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + flow_state = mocker.Mock(spec=_FlowState) + flow_state.auth_handler_id = auth_handler_id + await client.write(flow_state) + storage.write.assert_called_once_with({client.key(auth_handler_id): flow_state}) + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_handler_id", ["handler", "auth_handler"]) + async def test_delete(self, mocker, auth_handler_id): + storage = mocker.AsyncMock() + storage.delete.return_value = None + client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + await client.delete(auth_handler_id) + storage.delete.assert_called_once_with([client.key(auth_handler_id)]) + + @pytest.mark.asyncio + async def test_integration_with_memory_storage(self): + + flow_state_alpha = _FlowState(auth_handler_id="handler") + flow_state_beta = _FlowState(auth_handler_id="auth_handler") + + storage = MemoryStorage( + { + "some_data": MockStoreItem({"value": "test"}), + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": flow_state_alpha, + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler": flow_state_beta, + } + ) + baseline = MemoryStorage( + { + "some_data": MockStoreItem({"value": "test"}), + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": flow_state_alpha, + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler": flow_state_beta, + } + ) + + # helpers + async def read_check(*args, **kwargs): + res_storage = await storage.read(*args, **kwargs) + res_baseline = await baseline.read(*args, **kwargs) + assert res_storage == res_baseline + + async def write_both(*args, **kwargs): + await storage.write(*args, **kwargs) + await baseline.write(*args, **kwargs) + + async def delete_both(*args, **kwargs): + await storage.delete(*args, **kwargs) + await baseline.delete(*args, **kwargs) + + client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + + new_flow_state_alpha = _FlowState(auth_handler_id="handler") + flow_state_chi = _FlowState(auth_handler_id="chi") + + await client.write(new_flow_state_alpha) + await client.write(flow_state_chi) + await baseline.write( + { + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": new_flow_state_alpha.model_copy() + } + ) + await baseline.write( + { + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/chi": flow_state_chi.model_copy() + } + ) + + await write_both( + { + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": new_flow_state_alpha.model_copy() + } + ) + await write_both( + { + f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler": flow_state_beta.model_copy() + } + ) + await write_both({"other_data": MockStoreItem({"value": "more"})}) + + await delete_both(["some_data"]) + + await read_check( + [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler"], + target_cls=_FlowState, + ) + await read_check( + [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler"], + target_cls=_FlowState, + ) + await read_check( + [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/chi"], + target_cls=_FlowState, + ) + await read_check(["other_data"], target_cls=MockStoreItem) + await read_check(["some_data"], target_cls=MockStoreItem) From b250532fa61a12e229134baad7cbe2f887e5f9fa Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 21 Oct 2025 14:44:06 -0700 Subject: [PATCH 5/9] Adding tests for namespaced storage --- .../hosting/core/storage/__init__.py | 6 ++++ .../core/storage/_transcript/__init__.py | 2 ++ .../test_transcript_logger_middleware.py | 7 ++-- .../storage/test_transcript_store_memory.py | 2 +- .../wrappers/test_storage_namespace.py | 33 ++++++++----------- 5 files changed, 26 insertions(+), 24 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py index ef24fb75..98769a70 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py @@ -10,9 +10,14 @@ PagedResult, TranscriptStore, FileTranscriptStore, + TranscriptMemoryStore, +) +from ._wrappers import ( + _StorageNamespace ) __all__ = [ + "_StorageNamespace", "StoreItem", "Storage", "_AsyncStorageBase", @@ -22,6 +27,7 @@ "ConsoleTranscriptLogger", "TranscriptLoggerMiddleware", "TranscriptStore", + "TranscriptMemoryStore", "FileTranscriptLogger", "FileTranscriptStore", "PagedResult", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py index 2ccb3b4b..df6f30c5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py @@ -8,6 +8,7 @@ ) from .transcript_store import TranscriptStore from .transcript_file_store import FileTranscriptStore +from .transcript_memory_store import TranscriptMemoryStore __all__ = [ "TranscriptInfo", @@ -17,5 +18,6 @@ "FileTranscriptLogger", "PagedResult", "TranscriptStore", + "TranscriptMemoryStore", "FileTranscriptStore", ] diff --git a/tests/hosting_core/storage/test_transcript_logger_middleware.py b/tests/hosting_core/storage/test_transcript_logger_middleware.py index 7065baa2..e014b13b 100644 --- a/tests/hosting_core/storage/test_transcript_logger_middleware.py +++ b/tests/hosting_core/storage/test_transcript_logger_middleware.py @@ -4,14 +4,13 @@ import json import os from microsoft_agents.activity import Activity, ActivityEventNames, ActivityTypes -from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity -from microsoft_agents.hosting.core.middleware_set import TurnContext -from microsoft_agents.hosting.core.storage.transcript_logger import ( +from microsoft_agents.hosting.core import ClaimsIdentity, TurnContext +from microsoft_agents.hosting.core.storage import ( ConsoleTranscriptLogger, FileTranscriptLogger, TranscriptLoggerMiddleware, ) -from microsoft_agents.hosting.core.storage.transcript_memory_store import ( +from microsoft_agents.hosting.core.storage import ( TranscriptMemoryStore, ) import pytest diff --git a/tests/hosting_core/storage/test_transcript_store_memory.py b/tests/hosting_core/storage/test_transcript_store_memory.py index 691c3e02..f8fc1b59 100644 --- a/tests/hosting_core/storage/test_transcript_store_memory.py +++ b/tests/hosting_core/storage/test_transcript_store_memory.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone import pytest -from microsoft_agents.hosting.core.storage.transcript_memory_store import ( +from microsoft_agents.hosting.core.storage import ( TranscriptMemoryStore, PagedResult, ) diff --git a/tests/hosting_core/storage/wrappers/test_storage_namespace.py b/tests/hosting_core/storage/wrappers/test_storage_namespace.py index 78b13261..433a3f78 100644 --- a/tests/hosting_core/storage/wrappers/test_storage_namespace.py +++ b/tests/hosting_core/storage/wrappers/test_storage_namespace.py @@ -1,6 +1,6 @@ import pytest -from microsoft_agents.hosting.core.storage import MemoryStorage +from microsoft_agents.hosting.core.storage import MemoryStorage, _StorageNamespace from microsoft_agents.hosting.core._oauth import _FlowState, _FlowStorageClient from tests._common.storage.utils import MockStoreItem @@ -8,6 +8,7 @@ DEFAULTS = TEST_DEFAULTS() +NAMESPACE = "namespace" class TestStorageNamespace: @pytest.fixture @@ -15,8 +16,8 @@ def storage(self): return MemoryStorage() @pytest.fixture - def client(self, storage): - return _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + def client(self, storage) -> _StorageNamespace: + return _StorageNamespace(NAMESPACE, storage) @pytest.mark.asyncio @pytest.mark.parametrize( @@ -28,28 +29,22 @@ def client(self, storage): ], ) async def test_init_base_key(self, mocker, channel_id, user_id): - client = _FlowStorageClient(channel_id, user_id, mocker.Mock()) + prefix = f"auth/{channel_id}/{user_id}/" + client = _StorageNamespace(prefix, mocker.Mock()) assert client.base_key == f"auth/{channel_id}/{user_id}/" @pytest.mark.asyncio - async def test_init_fails_without_user_id(self, storage): + async def test_init_failures(self, storage): with pytest.raises(ValueError): - _FlowStorageClient(DEFAULTS.channel_id, "", storage) - - @pytest.mark.asyncio - async def test_init_fails_without_channel_id(self, storage): + _StorageNamespace("", storage) with pytest.raises(ValueError): - _FlowStorageClient("", DEFAULTS.user_id, storage) + _StorageNamespace(None, storage) + with pytest.raises(ValueError): + _StorageNamespace(None, None) - @pytest.mark.parametrize( - "auth_handler_id, expected", - [ - ("handler", "auth/__channel_id/__user_id/handler"), - ("auth_handler", "auth/__channel_id/__user_id/auth_handler"), - ], - ) - def test_key(self, client, auth_handler_id, expected): - assert client.key(auth_handler_id) == expected + def test_base_key_property(self): + storage = _StorageNamespace("base_key/", MemoryStorage()) + assert storage.base_key == "base_key/" @pytest.mark.asyncio @pytest.mark.parametrize("auth_handler_id", ["handler", "auth_handler"]) From 76c92a631e432abbd14e321cd35a54beabaf2a3a Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 22 Oct 2025 13:41:24 -0700 Subject: [PATCH 6/9] Integrating new storage wrappers --- .../microsoft_agents/activity/__init__.py | 8 +- .../activity/_utils/__init__.py | 14 + .../activity/_utils/_error_handling.py | 30 ++ .../{ => _utils}/_load_configuration.py | 0 .../core/_oauth/_flow_storage_client.py | 29 +- .../oauth/_handlers/_user_authorization.py | 16 +- .../hosting/core/app/oauth/authorization.py | 2 +- .../hosting/core/storage/_namespaces.py | 8 + .../hosting/core/storage/_type_aliases.py | 3 + .../core/storage/_wrappers/__init__.py | 6 +- .../core/storage/_wrappers/_cached_storage.py | 47 -- .../core/storage/_wrappers/_item_namespace.py | 18 + .../core/storage/_wrappers/_item_storage.py | 44 ++ .../core/storage/_wrappers/_memory_cache.py | 56 -- .../storage/_wrappers/_storage_namespace.py | 5 +- .../storage/wrappers/test_item_storage.py | 455 +++++++++++++++++ .../wrappers/test_storage_namespace.py | 481 +++++++++++++----- 17 files changed, 956 insertions(+), 266 deletions(-) create mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/_utils/__init__.py create mode 100644 libraries/microsoft-agents-activity/microsoft_agents/activity/_utils/_error_handling.py rename libraries/microsoft-agents-activity/microsoft_agents/activity/{ => _utils}/_load_configuration.py (100%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_namespaces.py delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_namespace.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_memory_cache.py create mode 100644 tests/hosting_core/storage/wrappers/test_item_storage.py diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index b18336b7..eacb62e4 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py @@ -101,7 +101,11 @@ from .channel_adapter_protocol import ChannelAdapterProtocol from .turn_context_protocol import TurnContextProtocol -from ._load_configuration import load_configuration_from_env +from ._utils import ( + load_configuration_from_env, + _raise_if_falsey, + _raise_if_none +) __all__ = [ "AgentsModel", @@ -194,6 +198,8 @@ "ConversationUpdateTypes", "MessageUpdateTypes", "load_configuration_from_env", + "_raise_if_falsey", + "_raise_if_none", "ChannelAdapterProtocol", "TurnContextProtocol", "TokenOrSignInResourceResponse", diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_utils/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_utils/__init__.py new file mode 100644 index 00000000..ce8459b3 --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/_utils/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from ._error_handling import ( + _raise_if_falsey, + _raise_if_none, +) +from ._load_configuration import load_configuration_from_env + +__all__ = [ + "_raise_if_falsey", + "_raise_if_none", + "load_configuration_from_env", +] \ No newline at end of file diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_utils/_error_handling.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_utils/_error_handling.py new file mode 100644 index 00000000..708705da --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/_utils/_error_handling.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +def _raise_if_none(label: str, **kwargs) -> None: + """Raises an exception if any of the provided keyword arguments are None. + + :param label: A label to include in the exception message. + :param kwargs: The keyword arguments to check. + :raises ValueError: If any of the provided keyword arguments are None. + """ + + none_args = [name for name, value in kwargs.items() if value is None] + if none_args: + raise ValueError( + f"{label}: The following arguments must be set and non-None: {', '.join(none_args)}" + ) + +def _raise_if_falsey(label: str, **kwargs) -> None: + """Raises an exception if any of the provided keyword arguments are falsey. + + :param label: A label to include in the exception message. + :param kwargs: The keyword arguments to check. + :raises ValueError: If any of the provided keyword arguments are falsey. + """ + + falsey_args = [name for name, value in kwargs.items() if not value] + if falsey_args: + raise ValueError( + f"{label}: The following arguments must be set and non-falsey (cannot be None or an empty string, for example): {', '.join(falsey_args)}" + ) \ No newline at end of file diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_utils/_load_configuration.py similarity index 100% rename from libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py rename to libraries/microsoft-agents-activity/microsoft_agents/activity/_utils/_load_configuration.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py index 98fec631..831a8b4a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py @@ -3,6 +3,8 @@ from typing import Optional +from microsoft_agents.activity import _raise_if_falsey + from ..storage import Storage from ._flow_state import _FlowState @@ -28,6 +30,7 @@ async def delete(self, keys: list[str]) -> None: # - CachedStorage class for two-tier storage # - Namespaced/PrefixedStorage class for namespacing keying # not generally thread or async safe (operations are not atomic) + class _FlowStorageClient: """Wrapper around Storage that manages sign-in state specific to each user and channel. @@ -38,28 +41,18 @@ def __init__( self, channel_id: str, user_id: str, - storage: Storage, - cache_class: Optional[type[Storage]] = None, - ): - """ - Args: - channel_id: used to create the prefix - user_id: used to create the prefix - storage: the backing storage - cache_class: the cache class to use (defaults to DummyCache, which performs no caching). - This cache's lifetime is tied to the FlowStorageClient instance. - """ + storage: Storage + ) -> None: + """Initializes the _FlowStorageClient. - if not user_id or not channel_id: - raise ValueError( - "FlowStorageClient.__init__(): channel_id and user_id must be set." - ) + :param channel_id: The ID of the channel. + :param user_id: The ID of the user. + :param storage: The backing storage. + """ + _raise_if_falsey("_FlowStorageClient.__init__", channel_id=channel_id, user_id=user_id, storage=storage) self._base_key = f"auth/{channel_id}/{user_id}/" self._storage = storage - if cache_class is None: - cache_class = _DummyCache - self._cache = cache_class() @property def base_key(self) -> str: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py index 902b0dd4..344a3feb 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py @@ -27,6 +27,11 @@ _FlowStorageClient, _FlowStateTag, ) +from microsoft_agents.hosting.core.storage import ( + _ItemNamespace, + Namespaces +) + from .._sign_in_response import _SignInResponse from ._authorization_handler import _AuthorizationHandler @@ -41,7 +46,7 @@ class _UserAuthorization(_AuthorizationHandler): async def _load_flow( self, context: TurnContext - ) -> tuple[_OAuthFlow, _FlowStorageClient]: + ) -> tuple[_OAuthFlow, _ItemNamespace[_FlowState]]: """Loads the OAuth flow for a specific auth handler. A new flow is created in Storage if none exists for the channel, user, and handler @@ -72,9 +77,13 @@ async def _load_flow( ms_app_id = context.turn_state.get(context.adapter.AGENT_IDENTITY_KEY).claims[ "aud" ] + + namespace = Namespaces.USER_AUTHORIZATION.format( + channel_id=channel_id, + user_id=user_id, + ) + flow_storage_client = _ItemNamespace(namespace, self._storage, _FlowState) - # try to load existing state - flow_storage_client = _FlowStorageClient(channel_id, user_id, self._storage) logger.info("Loading OAuth flow state from storage") flow_state: _FlowState = await flow_storage_client.read(self._id) if not flow_state: @@ -86,7 +95,6 @@ async def _load_flow( connection=self._handler.abs_oauth_connection_name, ms_app_id=ms_app_id, ) - # await flow_storage_client.write(flow_state) flow = _OAuthFlow(flow_state, user_token_client) return flow, flow_storage_client diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index b2403300..71009c21 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -130,7 +130,7 @@ def _sign_in_state_key(context: TurnContext) -> str: :return: A unique (across other values of channel_id and user_id) key for the sign-in state. :rtype: str """ - return f"auth:_SignInState:{context.activity.channel_id}:{context.activity.from_property.id}" + return Namespaces.format(channel_id=context.activity.channel_id, from_property_id=context.activity.from_property.id) async def _load_sign_in_state(self, context: TurnContext) -> Optional[_SignInState]: """Load the sign-in state from storage for the given context. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_namespaces.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_namespaces.py new file mode 100644 index 00000000..c61ddb16 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_namespaces.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +class _Namespaces: + """Storage key namespaces used by various components.""" + + USER_AUTHORIZATION = "auth/{channel_id}/{user_id}" + AUTHORIZATION = "auth/{channel_id}/{from_property_id}" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_type_aliases.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_type_aliases.py index f800f57f..c8fdd269 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_type_aliases.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_type_aliases.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import MutableMapping, Any JSON = MutableMapping[str, Any] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py index c1624735..f1273d27 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py @@ -1,10 +1,10 @@ -from ._cached_storage import _CachedStorage, _ClearableCachedStorage +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from ._item_storage import _ItemStorage from ._storage_namespace import _StorageNamespace __all__ = [ - "_CachedStorage", - "_ClearableCachedStorage", "_ItemStorage", "_StorageNamespace", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py deleted file mode 100644 index e6bcbb27..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_cached_storage.py +++ /dev/null @@ -1,47 +0,0 @@ -from typing import TypeVar, Generic - -from strenum import StrEnum - -from ..store_item import StoreItem -from ..storage import Storage - -StoreItemT = TypeVar("StoreItemT", bound=StoreItem) - - -class _CachedStorage(Storage): - """Wrapper around Storage that adds a caching layer.""" - - def __init__(self, storage: Storage, cache: Storage): - """Initialize CachedStorage with a storage and cache. - - Args: - storage: The backing storage. - cache: The caching storage. This should ideally be faster or at least - used by fewer clients than the backing storage. - """ - self._storage = storage - self._cache = cache - - async def read( - self, keys: list[str], *, target_cls: type[StoreItemT] = None, **kwargs - ) -> dict[str, StoreItemT]: - - data = await self._cache.read(keys, target_cls=target_cls, **kwargs) - if len(data.keys()) < len(keys): - missing_keys = [k for k in keys if k not in data] - storage_data = await self._storage.read( - missing_keys, target_cls=target_cls, **kwargs - ) - if storage_data: - await self._cache.write(storage_data) - data.update(storage_data) - - return data - - async def write(self, changes: dict[str, StoreItemT]) -> None: - await self._cache.write(changes) - await self._storage.write(changes) - - async def delete(self, keys: list[str]) -> None: - await self._cache.delete(keys) - await self._storage.delete(keys) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_namespace.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_namespace.py new file mode 100644 index 00000000..ba459bd3 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_namespace.py @@ -0,0 +1,18 @@ +from typing import TypeVar + +from ._item_storage import _ItemStorage +from ._storage_namespace import _StorageNamespace + +from ..store_item import StoreItem +from ..storage import Storage + +StoreItemT = TypeVar("StoreItemT", bound=StoreItem) + +class _ItemNamespace(_ItemStorage[StoreItemT]): + """Wrapper around StorageNamespace to handle single item operations within a namespace.""" + + def __init__(self, base_key: str, storage: Storage, item_cls: type[StoreItemT]): + super().__init__( + _StorageNamespace(base_key, storage), + item_cls + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py new file mode 100644 index 00000000..27b2a405 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_storage.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Generic, TypeVar + +from ..store_item import StoreItem +from ..storage import Storage + +StoreItemT = TypeVar("StoreItemT", bound=StoreItem) + +class _ItemStorage(Generic[StoreItemT]): + """Wrapper around Storage to handle single item operations.""" + + def __init__(self, storage: Storage, item_cls: type[StoreItemT]): + self._storage = storage + self._item_cls = item_cls + + async def read(self, key: str) -> StoreItemT | None: + """Reads an item from storage by key. + + :param key: The key of the item to read. + :type key: str + :return: The item if found, otherwise None. + """ + res = await self._storage.read([key], target_cls=self._item_cls) + return res.get(key) + + async def write(self, key: str, item: StoreItemT) -> None: + """Writes an item to storage with the given key. + + :param key: The key of the item to write. + :type key: str + :param item: The item to write. + :type item: StoreItemT + """ + await self._storage.write({key: item}) + + async def delete(self, key: str) -> None: + """Deletes an item from storage by key. + + :param key: The key of the item to delete. + :type key: str + """ + await self._storage.delete([key]) \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_memory_cache.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_memory_cache.py deleted file mode 100644 index e5e320fa..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_memory_cache.py +++ /dev/null @@ -1,56 +0,0 @@ -from datetime import datetime, timezone -from typing import TypeVar - -from ..memory_storage import MemoryStorage -from ..store_item import StoreItem - -StoreItemT = TypeVar("StoreItemT", bound=StoreItem) - - -class _DummyCache(Storage): - - async def read(self, keys: list[str], **kwargs) -> dict[str, _FlowState]: - return {} - - async def write(self, changes: dict[str, _FlowState]) -> None: - pass - - async def delete(self, keys: list[str]) -> None: - pass - - -class _MemoryCache(MemoryStorage): - def __init__(self, clear_interval: int = 300): - """In-memory cache that clears itself every `clear_interval` seconds. - - :param clear_interval: Time in seconds between automatic cache clears. - Defaults to 5 minutes. - :type clear_interval: int - :raises ValueError: If `clear_interval` is not positive. - """ - if clear_interval <= 0: - raise ValueError("clear_interval must be a positive integer.") - self._clear_interval = clear_interval - self._last_cleared = datetime.now(timezone.utc).timestamp() - - async def clear(self): - """Clears the cache if the clear interval has passed.""" - with self._lock: - now = datetime.now(timezone.utc).timestamp() - if now - self._last_cleared > self._clear_interval: - self._memory.clear() - self._last_cleared = now - - async def read( - self, keys: list[str], *, target_cls: StoreItemT = None, **kwargs - ) -> dict[str, StoreItemT]: - self.clear() - return await super().read(keys, target_cls=target_cls, **kwargs) - - async def write(self, changes: dict[str, StoreItem]): - self.clear() - return await super().write(changes) - - async def delete(self, keys: list[str]): - self.clear() - return await super().delete(keys) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py index 21cfe943..fe8defbe 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py @@ -1,6 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import TypeVar -from microsoft_agents.activity._error_handling import _raise_if_falsey +from microsoft_agents.activity import _raise_if_falsey from ..storage import Storage from ..store_item import StoreItem diff --git a/tests/hosting_core/storage/wrappers/test_item_storage.py b/tests/hosting_core/storage/wrappers/test_item_storage.py new file mode 100644 index 00000000..a968527c --- /dev/null +++ b/tests/hosting_core/storage/wrappers/test_item_storage.py @@ -0,0 +1,455 @@ +import pytest +import asyncio +from unittest.mock import AsyncMock + +from microsoft_agents.hosting.core.storage import MemoryStorage +from microsoft_agents.hosting.core.storage._wrappers._item_storage import _ItemStorage +from tests._common.storage.utils import MockStoreItem, MockStoreItemB + + +class TestItemStorage: + """Test cases for the _ItemStorage class.""" + + def test_init_valid_params(self): + """Test successful initialization with valid parameters.""" + storage = MemoryStorage() + item_cls = MockStoreItem + + item_storage = _ItemStorage(storage, item_cls) + + assert item_storage._storage is storage + assert item_storage._item_cls is item_cls + + def test_init_with_different_item_types(self): + """Test initialization with different StoreItem types.""" + storage = MemoryStorage() + + # Test with MockStoreItem + item_storage_a = _ItemStorage(storage, MockStoreItem) + assert item_storage_a._item_cls is MockStoreItem + + # Test with MockStoreItemB + item_storage_b = _ItemStorage(storage, MockStoreItemB) + assert item_storage_b._item_cls is MockStoreItemB + + @pytest.mark.asyncio + async def test_read_existing_key(self): + """Test reading an existing key from storage.""" + storage = AsyncMock() + item_cls = MockStoreItem + item_storage = _ItemStorage(storage, item_cls) + + key = "test_key" + mock_item = MockStoreItem({"data": "test_value"}) + storage.read.return_value = {key: mock_item} + + result = await item_storage.read(key) + + assert result == mock_item + storage.read.assert_called_once_with([key], target_cls=item_cls) + + @pytest.mark.asyncio + async def test_read_missing_key(self): + """Test reading a key that doesn't exist in storage.""" + storage = AsyncMock() + item_cls = MockStoreItem + item_storage = _ItemStorage(storage, item_cls) + + key = "missing_key" + storage.read.return_value = {} # Empty result for missing key + + result = await item_storage.read(key) + + assert result is None + storage.read.assert_called_once_with([key], target_cls=item_cls) + + @pytest.mark.asyncio + async def test_read_with_different_item_types(self): + """Test reading with different StoreItem types.""" + storage = AsyncMock() + + # Test with MockStoreItem + item_storage_a = _ItemStorage(storage, MockStoreItem) + mock_item_a = MockStoreItem({"type": "A"}) + storage.read.return_value = {"key": mock_item_a} + + result_a = await item_storage_a.read("key") + assert result_a == mock_item_a + storage.read.assert_called_with(["key"], target_cls=MockStoreItem) + + # Reset mock and test with MockStoreItemB + storage.reset_mock() + item_storage_b = _ItemStorage(storage, MockStoreItemB) + mock_item_b = MockStoreItemB({"type": "B"}, other_field=False) + storage.read.return_value = {"key": mock_item_b} + + result_b = await item_storage_b.read("key") + assert result_b == mock_item_b + storage.read.assert_called_with(["key"], target_cls=MockStoreItemB) + + @pytest.mark.asyncio + async def test_write_item(self): + """Test writing an item to storage.""" + storage = AsyncMock() + item_cls = MockStoreItem + item_storage = _ItemStorage(storage, item_cls) + + key = "test_key" + item = MockStoreItem({"data": "test_value"}) + + await item_storage.write(key, item) + + storage.write.assert_called_once_with({key: item}) + + @pytest.mark.asyncio + async def test_write_different_item_types(self): + """Test writing different StoreItem types.""" + storage = AsyncMock() + + # Test with MockStoreItem + item_storage_a = _ItemStorage(storage, MockStoreItem) + item_a = MockStoreItem({"type": "A"}) + + await item_storage_a.write("key_a", item_a) + storage.write.assert_called_with({"key_a": item_a}) + + # Reset mock and test with MockStoreItemB + storage.reset_mock() + item_storage_b = _ItemStorage(storage, MockStoreItemB) + item_b = MockStoreItemB({"type": "B"}, other_field=True) + + await item_storage_b.write("key_b", item_b) + storage.write.assert_called_with({"key_b": item_b}) + + @pytest.mark.asyncio + async def test_delete_key(self): + """Test deleting a key from storage.""" + storage = AsyncMock() + item_cls = MockStoreItem + item_storage = _ItemStorage(storage, item_cls) + + key = "test_key" + + await item_storage.delete(key) + + storage.delete.assert_called_once_with([key]) + + @pytest.mark.asyncio + async def test_delete_multiple_calls(self): + """Test deleting multiple keys with separate calls.""" + storage = AsyncMock() + item_cls = MockStoreItem + item_storage = _ItemStorage(storage, item_cls) + + keys = ["key1", "key2", "key3"] + + for key in keys: + await item_storage.delete(key) + + # Each delete call should be made separately + expected_calls = [([key],) for key in keys] + actual_calls = [call.args for call in storage.delete.call_args_list] + assert actual_calls == expected_calls + assert storage.delete.call_count == 3 + + @pytest.mark.asyncio + async def test_integration_with_memory_storage(self): + """Test integration with actual MemoryStorage.""" + memory_storage = MemoryStorage() + item_storage = _ItemStorage(memory_storage, MockStoreItem) + + # Test data + key = "test_key" + item = MockStoreItem({"user": "alice", "data": "test_data"}) + + # Initially, key should not exist + result = await item_storage.read(key) + assert result is None + + # Write item + await item_storage.write(key, item) + + # Read item back + result = await item_storage.read(key) + assert result == item + assert result.data == {"user": "alice", "data": "test_data"} + + # Update item + updated_item = MockStoreItem({"user": "alice", "data": "updated_data"}) + await item_storage.write(key, updated_item) + + # Read updated item + result = await item_storage.read(key) + assert result == updated_item + assert result.data == {"user": "alice", "data": "updated_data"} + + # Delete item + await item_storage.delete(key) + + # Verify deletion + result = await item_storage.read(key) + assert result is None + + @pytest.mark.asyncio + async def test_integration_with_different_item_types(self): + """Test integration with different StoreItem types.""" + memory_storage = MemoryStorage() + + # Test with MockStoreItem + item_storage_a = _ItemStorage(memory_storage, MockStoreItem) + item_a = MockStoreItem({"type": "A", "value": 123}) + + await item_storage_a.write("key_a", item_a) + result_a = await item_storage_a.read("key_a") + assert result_a == item_a + + # Test with MockStoreItemB - same underlying storage + item_storage_b = _ItemStorage(memory_storage, MockStoreItemB) + item_b = MockStoreItemB({"type": "B", "value": 456}, other_field=False) + + await item_storage_b.write("key_b", item_b) + result_b = await item_storage_b.read("key_b") + assert result_b == item_b + assert result_b.other_field is False + + # Verify both items exist in the same storage + # Note: We can't cross-read different types due to target_cls mismatch + # but we can verify they don't interfere with each other + result_a_after = await item_storage_a.read("key_a") + assert result_a_after == item_a + + @pytest.mark.asyncio + async def test_crud_operations_flow(self): + """Test a complete CRUD flow.""" + memory_storage = MemoryStorage() + item_storage = _ItemStorage(memory_storage, MockStoreItem) + + key = "flow_test_key" + + # Create + original_item = MockStoreItem({"status": "created", "version": 1}) + await item_storage.write(key, original_item) + + # Read + read_item = await item_storage.read(key) + assert read_item == original_item + + # Update + updated_item = MockStoreItem({"status": "updated", "version": 2}) + await item_storage.write(key, updated_item) + + # Read updated + read_updated = await item_storage.read(key) + assert read_updated == updated_item + assert read_updated != original_item + + # Delete + await item_storage.delete(key) + + # Read after delete + read_after_delete = await item_storage.read(key) + assert read_after_delete is None + + @pytest.mark.asyncio + async def test_multiple_keys_independence(self): + """Test that different keys are independent.""" + memory_storage = MemoryStorage() + item_storage = _ItemStorage(memory_storage, MockStoreItem) + + # Write multiple items + items = { + "key1": MockStoreItem({"id": 1, "name": "first"}), + "key2": MockStoreItem({"id": 2, "name": "second"}), + "key3": MockStoreItem({"id": 3, "name": "third"}) + } + + for key, item in items.items(): + await item_storage.write(key, item) + + # Verify all items exist + for key, expected_item in items.items(): + result = await item_storage.read(key) + assert result == expected_item + + # Delete one item + await item_storage.delete("key2") + + # Verify only the deleted item is gone + assert await item_storage.read("key1") == items["key1"] + assert await item_storage.read("key2") is None + assert await item_storage.read("key3") == items["key3"] + + # Update one item + updated_item = MockStoreItem({"id": 1, "name": "updated_first"}) + await item_storage.write("key1", updated_item) + + # Verify update doesn't affect other items + assert await item_storage.read("key1") == updated_item + assert await item_storage.read("key2") is None + assert await item_storage.read("key3") == items["key3"] + + @pytest.mark.asyncio + async def test_key_variations(self): + """Test various key formats and special characters.""" + memory_storage = MemoryStorage() + item_storage = _ItemStorage(memory_storage, MockStoreItem) + + test_keys = [ + "simple", + "key_with_underscores", + "key-with-dashes", + "key.with.dots", + "key/with/slashes", + "key:with:colons", + "key with spaces", + "KEY_UPPERCASE", + "key123numbers", + "special!@#$%chars", + "unicode_key_🔑", + "empty_value_key" + ] + + # Write items with various keys + for i, key in enumerate(test_keys): + item = MockStoreItem({"key_id": i, "key_name": key}) + await item_storage.write(key, item) + + # Read back and verify + for i, key in enumerate(test_keys): + result = await item_storage.read(key) + assert result is not None + assert result.data["key_id"] == i + assert result.data["key_name"] == key + + @pytest.mark.asyncio + async def test_storage_error_propagation(self): + """Test that storage errors are properly propagated.""" + storage = AsyncMock() + item_storage = _ItemStorage(storage, MockStoreItem) + + # Test read error propagation + storage.read.side_effect = Exception("Storage read error") + + with pytest.raises(Exception, match="Storage read error"): + await item_storage.read("test_key") + + # Test write error propagation + storage.read.side_effect = None # Reset + storage.write.side_effect = Exception("Storage write error") + + with pytest.raises(Exception, match="Storage write error"): + await item_storage.write("test_key", MockStoreItem({"data": "test"})) + + # Test delete error propagation + storage.write.side_effect = None # Reset + storage.delete.side_effect = Exception("Storage delete error") + + with pytest.raises(Exception, match="Storage delete error"): + await item_storage.delete("test_key") + + @pytest.mark.asyncio + async def test_concurrent_operations(self): + """Test concurrent operations on the same storage.""" + + memory_storage = MemoryStorage() + item_storage = _ItemStorage(memory_storage, MockStoreItem) + + # Define concurrent operations + async def write_operation(key_suffix: int): + item = MockStoreItem({"id": key_suffix, "data": f"data_{key_suffix}"}) + await item_storage.write(f"concurrent_key_{key_suffix}", item) + + async def read_operation(key_suffix: int): + return await item_storage.read(f"concurrent_key_{key_suffix}") + + # Execute concurrent writes + write_tasks = [write_operation(i) for i in range(5)] + await asyncio.gather(*write_tasks) + + # Execute concurrent reads + read_tasks = [read_operation(i) for i in range(5)] + results = await asyncio.gather(*read_tasks) + + # Verify all operations completed successfully + for i, result in enumerate(results): + assert result is not None + assert result.data["id"] == i + assert result.data["data"] == f"data_{i}" + + def test_type_annotations(self): + """Test that type annotations work correctly.""" + storage = MemoryStorage() + + # Test generic type specification + item_storage_a: _ItemStorage[MockStoreItem] = _ItemStorage(storage, MockStoreItem) + item_storage_b: _ItemStorage[MockStoreItemB] = _ItemStorage(storage, MockStoreItemB) + + # Verify the generic types are properly set + assert item_storage_a._item_cls is MockStoreItem + assert item_storage_b._item_cls is MockStoreItemB + + # This test mainly verifies that the type annotations don't cause runtime errors + # The actual type checking would be done by a static type checker like mypy + + @pytest.mark.asyncio + async def test_edge_cases(self): + """Test edge cases and boundary conditions.""" + memory_storage = MemoryStorage() + item_storage = _ItemStorage(memory_storage, MockStoreItem) + + # Test with very long key + long_key = "x" * 1000 + long_item = MockStoreItem({"data": "long_key_data"}) + await item_storage.write(long_key, long_item) + result = await item_storage.read(long_key) + assert result == long_item + + # Test with empty data + empty_data_item = MockStoreItem({}) + await item_storage.write("empty_data", empty_data_item) + result = await item_storage.read("empty_data") + assert result == empty_data_item + assert result + assert result.data == {} + + # Test overwriting existing key multiple times + key = "overwrite_test" + for i in range(3): + item = MockStoreItem({"version": i}) + await item_storage.write(key, item) + result = await item_storage.read(key) + assert result.data["version"] == i + + @pytest.mark.asyncio + async def test_docstring_examples(self): + """Test the examples that would be in docstrings.""" + memory_storage = MemoryStorage() + item_storage = _ItemStorage(memory_storage, MockStoreItem) + + # Example usage from docstrings + key = "user_session_123" + + # Reading non-existent key returns None + result = await item_storage.read(key) + assert result is None + + # Write an item + session_data = MockStoreItem({ + "user_id": "123", + "login_time": "2025-10-22T10:00:00Z", + "permissions": ["read", "write"] + }) + await item_storage.write(key, session_data) + + # Read the item back + retrieved_data = await item_storage.read(key) + assert retrieved_data == session_data + assert retrieved_data + assert retrieved_data.data["user_id"] == "123" + + # Delete the item + await item_storage.delete(key) + + # Verify deletion + result_after_delete = await item_storage.read(key) + assert result_after_delete is None \ No newline at end of file diff --git a/tests/hosting_core/storage/wrappers/test_storage_namespace.py b/tests/hosting_core/storage/wrappers/test_storage_namespace.py index 433a3f78..522dbf00 100644 --- a/tests/hosting_core/storage/wrappers/test_storage_namespace.py +++ b/tests/hosting_core/storage/wrappers/test_storage_namespace.py @@ -1,173 +1,384 @@ import pytest +from unittest.mock import Mock, AsyncMock -from microsoft_agents.hosting.core.storage import MemoryStorage, _StorageNamespace -from microsoft_agents.hosting.core._oauth import _FlowState, _FlowStorageClient - +from microsoft_agents.hosting.core.storage import MemoryStorage +from microsoft_agents.hosting.core.storage._wrappers._storage_namespace import _StorageNamespace from tests._common.storage.utils import MockStoreItem -from tests._common.data import TEST_DEFAULTS - -DEFAULTS = TEST_DEFAULTS() -NAMESPACE = "namespace" class TestStorageNamespace: - @pytest.fixture - def storage(self): - return MemoryStorage() + """Test cases for the _StorageNamespace class.""" - @pytest.fixture - def client(self, storage) -> _StorageNamespace: - return _StorageNamespace(NAMESPACE, storage) + def test_init_valid_params(self): + """Test successful initialization with valid parameters.""" + storage = MemoryStorage() + namespace = "test_namespace" + + storage_namespace = _StorageNamespace(namespace, storage) + + assert storage_namespace.base_key == namespace - @pytest.mark.asyncio - @pytest.mark.parametrize( - "channel_id, user_id", - [ - ("channel_id", "user_id"), - ("teams_id", "Bob"), - ("channel", "Alice"), - ], - ) - async def test_init_base_key(self, mocker, channel_id, user_id): - prefix = f"auth/{channel_id}/{user_id}/" - client = _StorageNamespace(prefix, mocker.Mock()) - assert client.base_key == f"auth/{channel_id}/{user_id}/" - - @pytest.mark.asyncio - async def test_init_failures(self, storage): + def test_init_empty_namespace_raises_error(self): + """Test that empty namespace raises ValueError.""" + storage = MemoryStorage() + with pytest.raises(ValueError): _StorageNamespace("", storage) + + def test_init_none_namespace_raises_error(self): + """Test that None namespace raises ValueError.""" + storage = MemoryStorage() + with pytest.raises(ValueError): _StorageNamespace(None, storage) - with pytest.raises(ValueError): - _StorageNamespace(None, None) + + def test_init_none_storage_raises_error(self): + """Test that None storage raises error from _raise_if_falsey.""" + # This would raise an error from _raise_if_falsey function + with pytest.raises(Exception): # Could be ValueError or another exception type + _StorageNamespace("namespace", None) def test_base_key_property(self): - storage = _StorageNamespace("base_key/", MemoryStorage()) - assert storage.base_key == "base_key/" + """Test the base_key property returns the correct value.""" + namespace = "auth/channel123/user456" + storage = MemoryStorage() + + storage_namespace = _StorageNamespace(namespace, storage) + + assert storage_namespace.base_key == namespace + + def test_key_method(self): + """Test the key method creates proper storage keys.""" + namespace = "test_namespace" + storage = MemoryStorage() + storage_namespace = _StorageNamespace(namespace, storage) + + vkey = "handler_id" + expected_key = "test_namespace:handler_id" + + assert storage_namespace.key(vkey) == expected_key + + def test_key_method_with_complex_namespace(self): + """Test the key method with complex namespace.""" + namespace = "auth/channel123/user456" + storage = MemoryStorage() + storage_namespace = _StorageNamespace(namespace, storage) + + vkey = "oauth_handler" + expected_key = "auth/channel123/user456:oauth_handler" + + assert storage_namespace.key(vkey) == expected_key + + @pytest.mark.asyncio + async def test_read_single_key(self): + """Test reading a single key.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + vkey = "handler1" + expected_storage_key = "test_namespace:handler1" + mock_item = MockStoreItem({"data": "test"}) + + storage.read.return_value = {expected_storage_key: mock_item} + + result = await storage_namespace.read([vkey], target_cls=MockStoreItem) + + assert result == {vkey: mock_item} + storage.read.assert_called_once_with([expected_storage_key], target_cls=MockStoreItem) + + @pytest.mark.asyncio + async def test_read_multiple_keys(self): + """Test reading multiple keys.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + vkeys = ["handler1", "handler2", "handler3"] + expected_storage_keys = [ + "test_namespace:handler1", + "test_namespace:handler2", + "test_namespace:handler3" + ] + mock_items = { + expected_storage_keys[0]: MockStoreItem({"data": "test1"}), + expected_storage_keys[1]: MockStoreItem({"data": "test2"}), + expected_storage_keys[2]: MockStoreItem({"data": "test3"}) + } + + storage.read.return_value = mock_items + + result = await storage_namespace.read(vkeys, target_cls=MockStoreItem) + + expected_result = { + "handler1": mock_items[expected_storage_keys[0]], + "handler2": mock_items[expected_storage_keys[1]], + "handler3": mock_items[expected_storage_keys[2]] + } + assert result == expected_result + storage.read.assert_called_once_with(expected_storage_keys, target_cls=MockStoreItem) @pytest.mark.asyncio - @pytest.mark.parametrize("auth_handler_id", ["handler", "auth_handler"]) - async def test_read(self, mocker, auth_handler_id): - storage = mocker.AsyncMock() - key = f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/{auth_handler_id}" - storage.read.return_value = {key: _FlowState()} - client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) - res = await client.read(auth_handler_id) - assert res is storage.read.return_value[key] + async def test_read_missing_keys(self): + """Test reading keys that don't exist in storage.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + vkeys = ["missing1", "missing2"] + storage.read.return_value = {} # No items found + + result = await storage_namespace.read(vkeys, target_cls=MockStoreItem) + + assert result == {} storage.read.assert_called_once_with( - [client.key(auth_handler_id)], target_cls=_FlowState + ["test_namespace:missing1", "test_namespace:missing2"], + target_cls=MockStoreItem ) @pytest.mark.asyncio - async def test_read_missing(self, mocker): - storage = mocker.AsyncMock() - storage.read.return_value = {} - client = _FlowStorageClient("__channel_id", "__user_id", storage) - res = await client.read("non_existent_handler") - assert res is None + async def test_read_partial_results(self): + """Test reading where only some keys exist.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + vkeys = ["exists", "missing"] + mock_item = MockStoreItem({"data": "found"}) + storage.read.return_value = {"test_namespace:exists": mock_item} + + result = await storage_namespace.read(vkeys, target_cls=MockStoreItem) + + assert result == {"exists": mock_item} storage.read.assert_called_once_with( - [client.key("non_existent_handler")], target_cls=_FlowState + ["test_namespace:exists", "test_namespace:missing"], + target_cls=MockStoreItem ) @pytest.mark.asyncio - @pytest.mark.parametrize("auth_handler_id", ["handler", "auth_handler"]) - async def test_write(self, mocker, auth_handler_id): - storage = mocker.AsyncMock() - storage.write.return_value = None - client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) - flow_state = mocker.Mock(spec=_FlowState) - flow_state.auth_handler_id = auth_handler_id - await client.write(flow_state) - storage.write.assert_called_once_with({client.key(auth_handler_id): flow_state}) + async def test_read_with_kwargs(self): + """Test reading with additional keyword arguments.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + vkey = "handler1" + mock_item = MockStoreItem({"data": "test"}) + storage.read.return_value = {"test_namespace:handler1": mock_item} + + await storage_namespace.read([vkey], target_cls=MockStoreItem, extra_param="value") + + storage.read.assert_called_once_with( + ["test_namespace:handler1"], + target_cls=MockStoreItem, + extra_param="value" + ) @pytest.mark.asyncio - @pytest.mark.parametrize("auth_handler_id", ["handler", "auth_handler"]) - async def test_delete(self, mocker, auth_handler_id): - storage = mocker.AsyncMock() - storage.delete.return_value = None - client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) - await client.delete(auth_handler_id) - storage.delete.assert_called_once_with([client.key(auth_handler_id)]) + async def test_write_single_item(self): + """Test writing a single item.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + vkey = "handler1" + item = MockStoreItem({"data": "test"}) + changes = {vkey: item} + + await storage_namespace.write(changes) + + expected_storage_changes = {"test_namespace:handler1": item} + storage.write.assert_called_once_with(expected_storage_changes) @pytest.mark.asyncio - async def test_integration_with_memory_storage(self): - - flow_state_alpha = _FlowState(auth_handler_id="handler") - flow_state_beta = _FlowState(auth_handler_id="auth_handler") + async def test_write_multiple_items(self): + """Test writing multiple items.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + changes = { + "handler1": MockStoreItem({"data": "test1"}), + "handler2": MockStoreItem({"data": "test2"}), + "handler3": MockStoreItem({"data": "test3"}) + } + + await storage_namespace.write(changes) + + expected_storage_changes = { + "test_namespace:handler1": changes["handler1"], + "test_namespace:handler2": changes["handler2"], + "test_namespace:handler3": changes["handler3"] + } + storage.write.assert_called_once_with(expected_storage_changes) - storage = MemoryStorage( - { - "some_data": MockStoreItem({"value": "test"}), - f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": flow_state_alpha, - f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler": flow_state_beta, - } - ) - baseline = MemoryStorage( - { - "some_data": MockStoreItem({"value": "test"}), - f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": flow_state_alpha, - f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler": flow_state_beta, - } - ) - - # helpers - async def read_check(*args, **kwargs): - res_storage = await storage.read(*args, **kwargs) - res_baseline = await baseline.read(*args, **kwargs) - assert res_storage == res_baseline - - async def write_both(*args, **kwargs): - await storage.write(*args, **kwargs) - await baseline.write(*args, **kwargs) + @pytest.mark.asyncio + async def test_write_empty_changes(self): + """Test writing with empty changes dictionary.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + await storage_namespace.write({}) + + storage.write.assert_called_once_with({}) - async def delete_both(*args, **kwargs): - await storage.delete(*args, **kwargs) - await baseline.delete(*args, **kwargs) + @pytest.mark.asyncio + async def test_delete_single_key(self): + """Test deleting a single key.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + vkey = "handler1" + + await storage_namespace.delete([vkey]) + + storage.delete.assert_called_once_with(["test_namespace:handler1"]) - client = _FlowStorageClient(DEFAULTS.channel_id, DEFAULTS.user_id, storage) + @pytest.mark.asyncio + async def test_delete_multiple_keys(self): + """Test deleting multiple keys.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + vkeys = ["handler1", "handler2", "handler3"] + + await storage_namespace.delete(vkeys) + + expected_storage_keys = [ + "test_namespace:handler1", + "test_namespace:handler2", + "test_namespace:handler3" + ] + storage.delete.assert_called_once_with(expected_storage_keys) - new_flow_state_alpha = _FlowState(auth_handler_id="handler") - flow_state_chi = _FlowState(auth_handler_id="chi") + @pytest.mark.asyncio + async def test_delete_empty_keys(self): + """Test deleting with empty keys list.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + await storage_namespace.delete([]) + + storage.delete.assert_called_once_with([]) - await client.write(new_flow_state_alpha) - await client.write(flow_state_chi) - await baseline.write( - { - f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": new_flow_state_alpha.model_copy() - } + @pytest.mark.asyncio + async def test_integration_with_memory_storage(self): + """Test integration with actual MemoryStorage.""" + memory_storage = MemoryStorage() + namespace = "auth/channel123/user456" + storage_namespace = _StorageNamespace(namespace, memory_storage) + + # Test data + item1 = MockStoreItem({"user": "alice", "state": "active"}) + item2 = MockStoreItem({"user": "bob", "state": "inactive"}) + + # Write items + await storage_namespace.write({ + "oauth_handler": item1, + "teams_handler": item2 + }) + + # Read items back + result = await storage_namespace.read( + ["oauth_handler", "teams_handler"], + target_cls=MockStoreItem ) - await baseline.write( - { - f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/chi": flow_state_chi.model_copy() - } + + assert len(result) == 2 + assert result["oauth_handler"] == item1 + assert result["teams_handler"] == item2 + + # Read non-existent item + missing_result = await storage_namespace.read(["missing"], target_cls=MockStoreItem) + assert missing_result == {} + + # Delete one item + await storage_namespace.delete(["oauth_handler"]) + + # Verify deletion + after_delete = await storage_namespace.read( + ["oauth_handler", "teams_handler"], + target_cls=MockStoreItem ) + assert len(after_delete) == 1 + assert "oauth_handler" not in after_delete + assert after_delete["teams_handler"] == item2 - await write_both( - { - f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler": new_flow_state_alpha.model_copy() - } - ) - await write_both( - { - f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler": flow_state_beta.model_copy() - } - ) - await write_both({"other_data": MockStoreItem({"value": "more"})}) + @pytest.mark.asyncio + async def test_namespace_isolation(self): + """Test that different namespaces are isolated from each other.""" + memory_storage = MemoryStorage() + + namespace1 = _StorageNamespace("namespace1", memory_storage) + namespace2 = _StorageNamespace("namespace2", memory_storage) + + # Write same key to both namespaces + item1 = MockStoreItem({"source": "namespace1"}) + item2 = MockStoreItem({"source": "namespace2"}) + + await namespace1.write({"shared_key": item1}) + await namespace2.write({"shared_key": item2}) + + # Read from both namespaces + result1 = await namespace1.read(["shared_key"], target_cls=MockStoreItem) + result2 = await namespace2.read(["shared_key"], target_cls=MockStoreItem) + + # Verify isolation + assert result1["shared_key"] == item1 + assert result2["shared_key"] == item2 + assert result1["shared_key"] != result2["shared_key"] + + # Delete from one namespace shouldn't affect the other + await namespace1.delete(["shared_key"]) + + result1_after = await namespace1.read(["shared_key"], target_cls=MockStoreItem) + result2_after = await namespace2.read(["shared_key"], target_cls=MockStoreItem) + + assert result1_after == {} + assert result2_after["shared_key"] == item2 - await delete_both(["some_data"]) + @pytest.mark.asyncio + async def test_key_with_colon_in_vkey(self): + """Test behavior when virtual key contains colon.""" + storage = AsyncMock() + namespace = "test_namespace" + storage_namespace = _StorageNamespace(namespace, storage) + + # Virtual key with colon + vkey = "oauth:handler:v2" + mock_item = MockStoreItem({"data": "test"}) + + # The storage key will be "test_namespace:oauth:handler:v2" + storage_key = "test_namespace:oauth:handler:v2" + storage.read.return_value = {storage_key: mock_item} + + result = await storage_namespace.read([vkey], target_cls=MockStoreItem) + + # Should correctly extract the virtual key from storage key + assert result == {vkey: mock_item} + storage.read.assert_called_once_with([storage_key], target_cls=MockStoreItem) - await read_check( - [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/handler"], - target_cls=_FlowState, - ) - await read_check( - [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/auth_handler"], - target_cls=_FlowState, - ) - await read_check( - [f"auth/{DEFAULTS.channel_id}/{DEFAULTS.user_id}/chi"], - target_cls=_FlowState, - ) - await read_check(["other_data"], target_cls=MockStoreItem) - await read_check(["some_data"], target_cls=MockStoreItem) + def test_different_namespace_formats(self): + """Test different namespace format patterns.""" + storage = MemoryStorage() + + test_cases = [ + "simple", + "auth/channel123/user456", + "oauth.handler.state", + "nested:namespace:with:colons", + "namespace_with_underscores", + "namespace-with-dashes" + ] + + for namespace in test_cases: + storage_namespace = _StorageNamespace(namespace, storage) + assert storage_namespace.base_key == namespace + + vkey = "test_key" + expected_key = f"{namespace}:{vkey}" + assert storage_namespace.key(vkey) == expected_key \ No newline at end of file From b1c79c97136a8e00a6dfee0e6ccdd121e141e6cf Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 22 Oct 2025 14:03:39 -0700 Subject: [PATCH 7/9] Shelving this code --- .../hosting/core/app/oauth/authorization.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index 71009c21..5927b0da 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -11,7 +11,11 @@ from microsoft_agents.activity import Activity, TokenResponse from ...turn_context import TurnContext -from ...storage import Storage +from ...storage import ( + Storage, + _ItemNamespace, + _Namespaces +) from ...authorization import Connections from ..._oauth import _FlowStateTag from ..state import TurnState @@ -67,6 +71,11 @@ def __init__( self._storage = storage self._connection_manager = connection_manager + self._sign_in_state_store = _ItemNamespace( + _Namespaces.AUTHORIZATION, + self._storage, + _SignInState, + ) self._sign_in_success_handler: Optional[ Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] @@ -117,20 +126,10 @@ def _init_handlers(self) -> None: connection_manager=self._connection_manager, auth_handler=auth_handler, ) - + @staticmethod - def _sign_in_state_key(context: TurnContext) -> str: - """Generate a unique storage key for the sign-in state based on the context. - - This is the key used to store and retrieve the sign-in state from storage, and - can be used to inspect or manipulate the state directly if needed. - - :param context: The turn context for the current turn of conversation. - :type context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` - :return: A unique (across other values of channel_id and user_id) key for the sign-in state. - :rtype: str - """ - return Namespaces.format(channel_id=context.activity.channel_id, from_property_id=context.activity.from_property.id) + def _sign_in_state_vkey(context: TurnContext) -> str: + return f"{context.activity.channel_id}:{context.activity.from_property.id}" async def _load_sign_in_state(self, context: TurnContext) -> Optional[_SignInState]: """Load the sign-in state from storage for the given context. @@ -228,7 +227,7 @@ async def _start_or_continue_sign_in( auth_handler_id = auth_handler_id or self._default_handler_id # check cached sign in state - sign_in_state = await self._load_sign_in_state(context) + sign_in_state = await self._sign_in_state_store.read(self._sign_in_state_vkey(context)) if not sign_in_state: # no existing sign-in state, create a new one sign_in_state = _SignInState(active_handler_id=auth_handler_id) @@ -243,6 +242,7 @@ async def _start_or_continue_sign_in( if sign_in_response.tag == _FlowStateTag.COMPLETE: if self._sign_in_success_handler: await self._sign_in_success_handler(context, state, auth_handler_id) + await self._sign_in_state_store.delete(self._sign_in_state_vkey(context)) await self._delete_sign_in_state(context) Authorization._cache_token( context, auth_handler_id, sign_in_response.token_response From 5402b1f409a8ec0cb60e8c7dfe9a96d3c64ca73b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 23 Oct 2025 08:20:18 -0700 Subject: [PATCH 8/9] Refactoring Authorization and UserAuthorization --- .../hosting/core/_oauth/__init__.py | 2 - .../core/_oauth/_flow_storage_client.py | 91 -------------- .../oauth/_handlers/_user_authorization.py | 5 +- .../hosting/core/app/oauth/authorization.py | 113 ++++++------------ .../hosting/core/storage/__init__.py | 7 ++ .../hosting/core/storage/_namespaces.py | 4 +- .../core/storage/_wrappers/__init__.py | 2 + .../core/storage/_wrappers/_item_namespace.py | 3 + .../hosting/core/storage/memory_storage.py | 2 +- 9 files changed, 56 insertions(+), 173 deletions(-) delete mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py index de88c8b4..82cc51a7 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/__init__.py @@ -2,7 +2,6 @@ # Licensed under the MIT License. from ._flow_state import _FlowState, _FlowStateTag, _FlowErrorTag -from ._flow_storage_client import _FlowStorageClient from ._oauth_flow import _OAuthFlow, _FlowResponse __all__ = [ @@ -10,6 +9,5 @@ "_FlowStateTag", "_FlowErrorTag", "_FlowResponse", - "_FlowStorageClient", "_OAuthFlow", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py deleted file mode 100644 index 831a8b4a..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import Optional - -from microsoft_agents.activity import _raise_if_falsey - -from ..storage import Storage -from ._flow_state import _FlowState - - -class _DummyCache(Storage): - - async def read(self, keys: list[str], **kwargs) -> dict[str, _FlowState]: - return {} - - async def write(self, changes: dict[str, _FlowState]) -> None: - pass - - async def delete(self, keys: list[str]) -> None: - pass - - -# class FlowStorageClient(StorageNamespace): -# def __init__(self, channel_id: str, user_id: str, storage: Storage): -# super().__init__(_FlowStorageClient(channel_id, user_id, storage)) - - -# this could be generalized. Ideas: -# - CachedStorage class for two-tier storage -# - Namespaced/PrefixedStorage class for namespacing keying -# not generally thread or async safe (operations are not atomic) - -class _FlowStorageClient: - """Wrapper around Storage that manages sign-in state specific to each user and channel. - - Uses the activity's channel_id and from.id to create a key prefix for storage operations. - """ - - def __init__( - self, - channel_id: str, - user_id: str, - storage: Storage - ) -> None: - """Initializes the _FlowStorageClient. - - :param channel_id: The ID of the channel. - :param user_id: The ID of the user. - :param storage: The backing storage. - """ - _raise_if_falsey("_FlowStorageClient.__init__", channel_id=channel_id, user_id=user_id, storage=storage) - - self._base_key = f"auth/{channel_id}/{user_id}/" - self._storage = storage - - @property - def base_key(self) -> str: - """Returns the prefix used for flow state storage isolation.""" - return self._base_key - - def key(self, auth_handler_id: str) -> str: - """Creates a storage key for a specific sign-in handler.""" - return f"{self._base_key}{auth_handler_id}" - - async def read(self, auth_handler_id: str) -> Optional[_FlowState]: - """Reads the flow state for a specific authentication handler.""" - key: str = self.key(auth_handler_id) - data = await self._cache.read([key], target_cls=_FlowState) - if key not in data: - data = await self._storage.read([key], target_cls=_FlowState) - if key not in data: - return None - await self._cache.write({key: data[key]}) - return _FlowState.model_validate(data.get(key)) - - async def write(self, value: _FlowState) -> None: - """Saves the flow state for a specific authentication handler.""" - key: str = self.key(value.auth_handler_id) - cached_state = await self._cache.read([key], target_cls=_FlowState) - if not cached_state or cached_state != value: - await self._cache.write({key: value}) - await self._storage.write({key: value}) - - async def delete(self, auth_handler_id: str) -> None: - """Deletes the flow state for a specific authentication handler.""" - key: str = self.key(auth_handler_id) - cached_state = await self._cache.read([key], target_cls=_FlowState) - if cached_state: - await self._cache.delete([key]) - await self._storage.delete([key]) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py index 344a3feb..8c3e475d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/_user_authorization.py @@ -24,12 +24,11 @@ _OAuthFlow, _FlowResponse, _FlowState, - _FlowStorageClient, _FlowStateTag, ) from microsoft_agents.hosting.core.storage import ( _ItemNamespace, - Namespaces + _Namespaces ) from .._sign_in_response import _SignInResponse @@ -78,7 +77,7 @@ async def _load_flow( "aud" ] - namespace = Namespaces.USER_AUTHORIZATION.format( + namespace = _Namespaces._USER_AUTHORIZATION.format( channel_id=channel_id, user_id=user_id, ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index 5927b0da..a8c4c1f3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -14,6 +14,7 @@ from ...storage import ( Storage, _ItemNamespace, + _StorageNamespace, _Namespaces ) from ...authorization import Connections @@ -71,11 +72,7 @@ def __init__( self._storage = storage self._connection_manager = connection_manager - self._sign_in_state_store = _ItemNamespace( - _Namespaces.AUTHORIZATION, - self._storage, - _SignInState, - ) + self._sign_in_state_store = _ItemNamespace(_Namespaces.SIGN_IN_STATE, self._storage, _SignInState) self._sign_in_success_handler: Optional[ Callable[[TurnContext, TurnState, Optional[str]], Awaitable[None]] @@ -122,71 +119,10 @@ def _init_handlers(self) -> None: raise ValueError(f"Auth type {auth_type} not recognized.") self._handlers[name] = AUTHORIZATION_TYPE_MAP[auth_type]( - storage=self._storage, + storage=_StorageNamespace(name, self._storage), connection_manager=self._connection_manager, auth_handler=auth_handler, ) - - @staticmethod - def _sign_in_state_vkey(context: TurnContext) -> str: - return f"{context.activity.channel_id}:{context.activity.from_property.id}" - - async def _load_sign_in_state(self, context: TurnContext) -> Optional[_SignInState]: - """Load the sign-in state from storage for the given context. - - :param context: The turn context for the current turn of conversation. - :type context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` - :return: The sign-in state if found, None otherwise. - :rtype: Optional[:class:`microsoft_agents.hosting.core.app.oauth._sign_in_state._SignInState`] - """ - key = self._sign_in_state_key(context) - return (await self._storage.read([key], target_cls=_SignInState)).get(key) - - async def _save_sign_in_state( - self, context: TurnContext, state: _SignInState - ) -> None: - """Save the sign-in state to storage for the given context. - - :param context: The turn context for the current turn of conversation. - :type context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` - :param state: The sign-in state to save. - :type state: :class:`microsoft_agents.hosting.core.app.oauth._sign_in_state._SignInState` - """ - key = self._sign_in_state_key(context) - await self._storage.write({key: state}) - - async def _delete_sign_in_state(self, context: TurnContext) -> None: - """Delete the sign-in state from storage for the given context. - - :param context: The turn context for the current turn of conversation. - :type context: :class:`microsoft_agents.hosting.core.turn_context.TurnContext` - """ - key = self._sign_in_state_key(context) - await self._storage.delete([key]) - - @staticmethod - def _cache_key(context: TurnContext, handler_id: str) -> str: - return f"{Authorization._sign_in_state_key(context)}:{handler_id}:token" - - @staticmethod - def _get_cached_token( - context: TurnContext, handler_id: str - ) -> Optional[TokenResponse]: - key = Authorization._cache_key(context, handler_id) - return cast(Optional[TokenResponse], context.turn_state.get(key)) - - @staticmethod - def _cache_token( - context: TurnContext, handler_id: str, token_response: TokenResponse - ) -> None: - key = Authorization._cache_key(context, handler_id) - context.turn_state[key] = token_response - - @staticmethod - def _delete_cached_token(context: TurnContext, handler_id: str) -> None: - key = Authorization._cache_key(context, handler_id) - if key in context.turn_state: - del context.turn_state[key] def _resolve_handler(self, handler_id: str) -> _AuthorizationHandler: """Resolve the auth handler by its ID. @@ -227,7 +163,8 @@ async def _start_or_continue_sign_in( auth_handler_id = auth_handler_id or self._default_handler_id # check cached sign in state - sign_in_state = await self._sign_in_state_store.read(self._sign_in_state_vkey(context)) + sign_in_state_key = self._sign_in_state_key(context) + sign_in_state = await self._sign_in_state_store.read(sign_in_state_key) if not sign_in_state: # no existing sign-in state, create a new one sign_in_state = _SignInState(active_handler_id=auth_handler_id) @@ -242,8 +179,7 @@ async def _start_or_continue_sign_in( if sign_in_response.tag == _FlowStateTag.COMPLETE: if self._sign_in_success_handler: await self._sign_in_success_handler(context, state, auth_handler_id) - await self._sign_in_state_store.delete(self._sign_in_state_vkey(context)) - await self._delete_sign_in_state(context) + await self._sign_in_state_store.delete(sign_in_state_key) Authorization._cache_token( context, auth_handler_id, sign_in_response.token_response ) @@ -251,12 +187,12 @@ async def _start_or_continue_sign_in( elif sign_in_response.tag == _FlowStateTag.FAILURE: if self._sign_in_failure_handler: await self._sign_in_failure_handler(context, state, auth_handler_id) - await self._delete_sign_in_state(context) + await self._sign_in_state_store.delete(sign_in_state_key) elif sign_in_response.tag in [_FlowStateTag.BEGIN, _FlowStateTag.CONTINUE]: # store continuation activity and wait for next turn sign_in_state.continuation_activity = context.activity - await self._save_sign_in_state(context, sign_in_state) + await self._sign_in_state_store.write(sign_in_state_key, sign_in_state) return sign_in_response @@ -274,7 +210,7 @@ async def sign_out( auth_handler_id = auth_handler_id or self._default_handler_id handler = self._resolve_handler(auth_handler_id) Authorization._delete_cached_token(context, auth_handler_id) - await self._delete_sign_in_state(context) + await self._sign_in_state_store.delete(self._sign_in_state_key(context)) await handler._sign_out(context) async def _on_turn_auth_intercept( @@ -294,7 +230,8 @@ async def _on_turn_auth_intercept( :return: A tuple indicating whether the turn should be skipped and the continuation activity if applicable. :rtype: tuple[bool, Optional[:class:`microsoft_agents.activity.Activity`]] """ - sign_in_state = await self._load_sign_in_state(context) + sign_in_state_key = self._sign_in_state_key(context) + sign_in_state = await self._sign_in_state_store.read(sign_in_state_key) if sign_in_state: auth_handler_id = sign_in_state.active_handler_id @@ -407,3 +344,31 @@ def on_sign_in_failure( :type handler: Callable[[:class:`microsoft_agents.hosting.core.turn_context.TurnContext`, :class:`microsoft_agents.hosting.core.app.state.turn_state.TurnState`, Optional[str]], Awaitable[None]] """ self._sign_in_failure_handler = handler + + @staticmethod + def _sign_in_state_key(context: TurnContext) -> str: + return f"{_Namespaces.SIGN_IN_STATE}.{context.activity.channel_id}:{context.activity.from_property.id}" + + @staticmethod + def _cache_key(context: TurnContext, handler_id: str) -> str: + return f"{Authorization._sign_in_state_key(context)}:{handler_id}:token" + + @staticmethod + def _get_cached_token( + context: TurnContext, handler_id: str + ) -> Optional[TokenResponse]: + key = Authorization._cache_key(context, handler_id) + return cast(Optional[TokenResponse], context.turn_state.get(key)) + + @staticmethod + def _cache_token( + context: TurnContext, handler_id: str, token_response: TokenResponse + ) -> None: + key = Authorization._cache_key(context, handler_id) + context.turn_state[key] = token_response + + @staticmethod + def _delete_cached_token(context: TurnContext, handler_id: str) -> None: + key = Authorization._cache_key(context, handler_id) + if key in context.turn_state: + del context.turn_state[key] \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py index 98769a70..ef70507b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py @@ -1,3 +1,4 @@ +from ._namespaces import _Namespaces from .store_item import StoreItem from .storage import Storage, _AsyncStorageBase from .memory_storage import MemoryStorage @@ -14,6 +15,8 @@ ) from ._wrappers import ( _StorageNamespace + _ItemNamespace, + _ItemStorage, ) __all__ = [ @@ -31,4 +34,8 @@ "FileTranscriptLogger", "FileTranscriptStore", "PagedResult", + "_Namespaces", + "_StorageNamespace", + "_ItemNamespace", + "_ItemStorage", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_namespaces.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_namespaces.py index c61ddb16..6c705368 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_namespaces.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_namespaces.py @@ -4,5 +4,5 @@ class _Namespaces: """Storage key namespaces used by various components.""" - USER_AUTHORIZATION = "auth/{channel_id}/{user_id}" - AUTHORIZATION = "auth/{channel_id}/{from_property_id}" \ No newline at end of file + SIGN_IN_STATE = "auth.sign_in_state" + USER_AUTHORIZATION = "auth.user_authorization.{channel_id}:{from_property_id}" \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py index f1273d27..27015940 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py @@ -1,10 +1,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from ._item_namespace import _ItemNamespace from ._item_storage import _ItemStorage from ._storage_namespace import _StorageNamespace __all__ = [ + "_ItemNamespace", "_ItemStorage", "_StorageNamespace", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_namespace.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_namespace.py index ba459bd3..a498a966 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_namespace.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_namespace.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import TypeVar from ._item_storage import _ItemStorage diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py index 5f04c631..d03769f5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/memory_storage.py @@ -26,8 +26,8 @@ async def read( if not target_cls: raise ValueError("Storage.read(): target_cls cannot be None.") - result: dict[str, StoreItem] = {} with self._lock: + result: dict[str, StoreItem] = {} for key in keys: if key == "": raise ValueError("MemoryStorage.read(): key cannot be empty") From 050fc043518c9b34931ec37d762fd8ce97696bbb Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 23 Oct 2025 08:27:27 -0700 Subject: [PATCH 9/9] ItemNamespace tests intro --- .../__init__.py} | 0 .../test_file_transcript_storage.py | 0 .../test_transcript_logger_middleware.py | 0 .../test_transcript_store_memory.py | 0 .../storage/wrappers/test_item_namespace.py | 551 ++++++++++++++++++ .../storage/wrappers/test_memory_cache.py | 0 6 files changed, 551 insertions(+) rename tests/hosting_core/storage/{wrappers/test_cached_storage.py => transcript/__init__.py} (100%) rename tests/hosting_core/storage/{ => transcript}/test_file_transcript_storage.py (100%) rename tests/hosting_core/storage/{ => transcript}/test_transcript_logger_middleware.py (100%) rename tests/hosting_core/storage/{ => transcript}/test_transcript_store_memory.py (100%) create mode 100644 tests/hosting_core/storage/wrappers/test_item_namespace.py delete mode 100644 tests/hosting_core/storage/wrappers/test_memory_cache.py diff --git a/tests/hosting_core/storage/wrappers/test_cached_storage.py b/tests/hosting_core/storage/transcript/__init__.py similarity index 100% rename from tests/hosting_core/storage/wrappers/test_cached_storage.py rename to tests/hosting_core/storage/transcript/__init__.py diff --git a/tests/hosting_core/storage/test_file_transcript_storage.py b/tests/hosting_core/storage/transcript/test_file_transcript_storage.py similarity index 100% rename from tests/hosting_core/storage/test_file_transcript_storage.py rename to tests/hosting_core/storage/transcript/test_file_transcript_storage.py diff --git a/tests/hosting_core/storage/test_transcript_logger_middleware.py b/tests/hosting_core/storage/transcript/test_transcript_logger_middleware.py similarity index 100% rename from tests/hosting_core/storage/test_transcript_logger_middleware.py rename to tests/hosting_core/storage/transcript/test_transcript_logger_middleware.py diff --git a/tests/hosting_core/storage/test_transcript_store_memory.py b/tests/hosting_core/storage/transcript/test_transcript_store_memory.py similarity index 100% rename from tests/hosting_core/storage/test_transcript_store_memory.py rename to tests/hosting_core/storage/transcript/test_transcript_store_memory.py diff --git a/tests/hosting_core/storage/wrappers/test_item_namespace.py b/tests/hosting_core/storage/wrappers/test_item_namespace.py new file mode 100644 index 00000000..d7c7cc42 --- /dev/null +++ b/tests/hosting_core/storage/wrappers/test_item_namespace.py @@ -0,0 +1,551 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from unittest.mock import Mock, AsyncMock + +from microsoft_agents.hosting.core.storage import MemoryStorage +from microsoft_agents.hosting.core.storage._wrappers._item_namespace import _ItemNamespace +from microsoft_agents.hosting.core.storage._wrappers._storage_namespace import _StorageNamespace +from tests._common.storage.utils import MockStoreItem, MockStoreItemB + + +class TestItemNamespace: + """Test cases for the _ItemNamespace class.""" + + def test_init_valid_params(self): + """Test successful initialization with valid parameters.""" + storage = MemoryStorage() + base_key = "test_namespace" + item_cls = MockStoreItem + + item_namespace = _ItemNamespace(base_key, storage, item_cls) + + # Verify that it properly wraps the storage in a _StorageNamespace + assert isinstance(item_namespace._storage, _StorageNamespace) + assert item_namespace._storage.base_key == base_key + assert item_namespace._item_cls is item_cls + + def test_init_with_different_item_types(self): + """Test initialization with different StoreItem types.""" + storage = MemoryStorage() + base_key = "test_namespace" + + # Test with MockStoreItem + item_namespace_a = _ItemNamespace(base_key, storage, MockStoreItem) + assert item_namespace_a._item_cls is MockStoreItem + + # Test with MockStoreItemB + item_namespace_b = _ItemNamespace(base_key, storage, MockStoreItemB) + assert item_namespace_b._item_cls is MockStoreItemB + + def test_init_with_different_base_keys(self): + """Test initialization with different base key formats.""" + storage = MemoryStorage() + + test_base_keys = [ + "simple", + "auth/channel123/user456", + "oauth.handler.state", + "nested:namespace:with:colons", + "namespace_with_underscores", + "namespace-with-dashes" + ] + + for base_key in test_base_keys: + item_namespace = _ItemNamespace(base_key, storage, MockStoreItem) + assert item_namespace._storage.base_key == base_key + + def test_init_empty_base_key_raises_error(self): + """Test that empty base_key raises ValueError.""" + storage = MemoryStorage() + + with pytest.raises(ValueError): + _ItemNamespace("", storage, MockStoreItem) + + def test_init_none_base_key_raises_error(self): + """Test that None base_key raises ValueError.""" + storage = MemoryStorage() + + with pytest.raises(ValueError): + _ItemNamespace(None, storage, MockStoreItem) + + def test_init_none_storage_raises_error(self): + """Test that None storage raises error.""" + with pytest.raises(Exception): + _ItemNamespace("namespace", None, MockStoreItem) + + @pytest.mark.asyncio + async def test_read_existing_key(self): + """Test reading an existing key from namespaced storage.""" + storage = AsyncMock() + base_key = "test_namespace" + item_namespace = _ItemNamespace(base_key, storage, MockStoreItem) + + key = "test_key" + mock_item = MockStoreItem({"data": "test_value"}) + + # Mock the underlying storage to return the item + storage.read.return_value = {f"{base_key}:{key}": mock_item} + + result = await item_namespace.read(key) + + assert result == mock_item + storage.read.assert_called_once_with([f"{base_key}:{key}"], target_cls=MockStoreItem) + + @pytest.mark.asyncio + async def test_read_missing_key(self): + """Test reading a key that doesn't exist in namespaced storage.""" + storage = AsyncMock() + base_key = "test_namespace" + item_namespace = _ItemNamespace(base_key, storage, MockStoreItem) + + key = "missing_key" + storage.read.return_value = {} # Empty result for missing key + + result = await item_namespace.read(key) + + assert result is None + storage.read.assert_called_once_with([f"{base_key}:{key}"], target_cls=MockStoreItem) + + @pytest.mark.asyncio + async def test_write_item(self): + """Test writing an item to namespaced storage.""" + storage = AsyncMock() + base_key = "test_namespace" + item_namespace = _ItemNamespace(base_key, storage, MockStoreItem) + + key = "test_key" + item = MockStoreItem({"data": "test_value"}) + + await item_namespace.write(key, item) + + storage.write.assert_called_once_with({f"{base_key}:{key}": item}) + + @pytest.mark.asyncio + async def test_delete_key(self): + """Test deleting a key from namespaced storage.""" + storage = AsyncMock() + base_key = "test_namespace" + item_namespace = _ItemNamespace(base_key, storage, MockStoreItem) + + key = "test_key" + + await item_namespace.delete(key) + + storage.delete.assert_called_once_with([f"{base_key}:{key}"]) + + @pytest.mark.asyncio + async def test_integration_with_memory_storage(self): + """Test integration with actual MemoryStorage.""" + memory_storage = MemoryStorage() + base_key = "auth/channel123/user456" + item_namespace = _ItemNamespace(base_key, memory_storage, MockStoreItem) + + # Test data + key = "oauth_state" + item = MockStoreItem({"user": "alice", "state": "authorized"}) + + # Initially, key should not exist + result = await item_namespace.read(key) + assert result is None + + # Write item + await item_namespace.write(key, item) + + # Read item back + result = await item_namespace.read(key) + assert result == item + assert result.data == {"user": "alice", "state": "authorized"} + + # Update item + updated_item = MockStoreItem({"user": "alice", "state": "refreshed"}) + await item_namespace.write(key, updated_item) + + # Read updated item + result = await item_namespace.read(key) + assert result == updated_item + assert result.data == {"user": "alice", "state": "refreshed"} + + # Delete item + await item_namespace.delete(key) + + # Verify deletion + result = await item_namespace.read(key) + assert result is None + + @pytest.mark.asyncio + async def test_namespace_isolation(self): + """Test that different namespaces are isolated from each other.""" + memory_storage = MemoryStorage() + + namespace1 = _ItemNamespace("auth/user1", memory_storage, MockStoreItem) + namespace2 = _ItemNamespace("auth/user2", memory_storage, MockStoreItem) + + # Write same key to both namespaces + key = "oauth_state" + item1 = MockStoreItem({"user": "user1", "token": "token1"}) + item2 = MockStoreItem({"user": "user2", "token": "token2"}) + + await namespace1.write(key, item1) + await namespace2.write(key, item2) + + # Read from both namespaces + result1 = await namespace1.read(key) + result2 = await namespace2.read(key) + + # Verify isolation + assert result1 == item1 + assert result2 == item2 + assert result1 != result2 + + # Delete from one namespace shouldn't affect the other + await namespace1.delete(key) + + result1_after = await namespace1.read(key) + result2_after = await namespace2.read(key) + + assert result1_after is None + assert result2_after == item2 + + @pytest.mark.asyncio + async def test_different_item_types_same_namespace(self): + """Test different StoreItem types in the same namespace.""" + memory_storage = MemoryStorage() + base_key = "shared_namespace" + + # Create namespaces with different item types + namespace_a = _ItemNamespace(base_key, memory_storage, MockStoreItem) + namespace_b = _ItemNamespace(base_key, memory_storage, MockStoreItemB) + + # Write different item types to different keys + item_a = MockStoreItem({"type": "A", "value": 123}) + item_b = MockStoreItemB({"type": "B", "value": 456}, other_field=False) + + await namespace_a.write("key_a", item_a) + await namespace_b.write("key_b", item_b) + + # Read back items with their respective namespaces + result_a = await namespace_a.read("key_a") + result_b = await namespace_b.read("key_b") + + assert result_a == item_a + assert result_b == item_b + assert result_b.other_field is False + + # Verify that trying to read with wrong type fails gracefully + # (This depends on the underlying storage implementation) + result_cross = await namespace_a.read("key_b") + # This might return None or raise an exception depending on implementation + + @pytest.mark.asyncio + async def test_crud_operations_flow(self): + """Test a complete CRUD flow with namespaced storage.""" + memory_storage = MemoryStorage() + base_key = "flow_test" + item_namespace = _ItemNamespace(base_key, memory_storage, MockStoreItem) + + key = "session_data" + + # Create + original_item = MockStoreItem({"status": "created", "version": 1}) + await item_namespace.write(key, original_item) + + # Read + read_item = await item_namespace.read(key) + assert read_item == original_item + + # Update + updated_item = MockStoreItem({"status": "updated", "version": 2}) + await item_namespace.write(key, updated_item) + + # Read updated + read_updated = await item_namespace.read(key) + assert read_updated == updated_item + assert read_updated != original_item + + # Delete + await item_namespace.delete(key) + + # Read after delete + read_after_delete = await item_namespace.read(key) + assert read_after_delete is None + + @pytest.mark.asyncio + async def test_multiple_keys_independence(self): + """Test that different keys within the same namespace are independent.""" + memory_storage = MemoryStorage() + base_key = "multi_key_test" + item_namespace = _ItemNamespace(base_key, memory_storage, MockStoreItem) + + # Write multiple items + items = { + "session1": MockStoreItem({"id": 1, "user": "alice"}), + "session2": MockStoreItem({"id": 2, "user": "bob"}), + "session3": MockStoreItem({"id": 3, "user": "charlie"}) + } + + for key, item in items.items(): + await item_namespace.write(key, item) + + # Verify all items exist + for key, expected_item in items.items(): + result = await item_namespace.read(key) + assert result == expected_item + + # Delete one item + await item_namespace.delete("session2") + + # Verify only the deleted item is gone + assert await item_namespace.read("session1") == items["session1"] + assert await item_namespace.read("session2") is None + assert await item_namespace.read("session3") == items["session3"] + + # Update one item + updated_item = MockStoreItem({"id": 1, "user": "alice_updated"}) + await item_namespace.write("session1", updated_item) + + # Verify update doesn't affect other items + assert await item_namespace.read("session1") == updated_item + assert await item_namespace.read("session2") is None + assert await item_namespace.read("session3") == items["session3"] + + @pytest.mark.asyncio + async def test_complex_namespace_key_combinations(self): + """Test various combinations of namespaces and keys.""" + memory_storage = MemoryStorage() + + test_cases = [ + ("auth/channel123/user456", "oauth_handler"), + ("simple", "key_with_underscores"), + ("namespace.with.dots", "key-with-dashes"), + ("namespace:with:colons", "key:also:with:colons"), + ("namespace with spaces", "key with spaces"), + ("unicode_namespace_🔑", "unicode_key_🗝️") + ] + + for base_key, key in test_cases: + item_namespace = _ItemNamespace(base_key, memory_storage, MockStoreItem) + item = MockStoreItem({"namespace": base_key, "key": key}) + + # Write item + await item_namespace.write(key, item) + + # Read item back + result = await item_namespace.read(key) + assert result == item + assert result.data["namespace"] == base_key + assert result.data["key"] == key + + # Clean up for next iteration + await item_namespace.delete(key) + + @pytest.mark.asyncio + async def test_inheritance_from_item_storage(self): + """Test that _ItemNamespace properly inherits from _ItemStorage.""" + memory_storage = MemoryStorage() + base_key = "inheritance_test" + item_namespace = _ItemNamespace(base_key, memory_storage, MockStoreItem) + + # Verify that it has the expected methods from _ItemStorage + assert hasattr(item_namespace, 'read') + assert hasattr(item_namespace, 'write') + assert hasattr(item_namespace, 'delete') + assert hasattr(item_namespace, '_storage') + assert hasattr(item_namespace, '_item_cls') + + # Test that the methods work as expected (basic functionality test) + key = "test_key" + item = MockStoreItem({"test": "data"}) + + await item_namespace.write(key, item) + result = await item_namespace.read(key) + assert result == item + + await item_namespace.delete(key) + result_after_delete = await item_namespace.read(key) + assert result_after_delete is None + + @pytest.mark.asyncio + async def test_storage_error_propagation(self): + """Test that storage errors are properly propagated through the namespace wrapper.""" + storage = AsyncMock() + base_key = "error_test" + item_namespace = _ItemNamespace(base_key, storage, MockStoreItem) + + # Test read error propagation + storage.read.side_effect = Exception("Storage read error") + + with pytest.raises(Exception, match="Storage read error"): + await item_namespace.read("test_key") + + # Test write error propagation + storage.read.side_effect = None # Reset + storage.write.side_effect = Exception("Storage write error") + + with pytest.raises(Exception, match="Storage write error"): + await item_namespace.write("test_key", MockStoreItem({"data": "test"})) + + # Test delete error propagation + storage.write.side_effect = None # Reset + storage.delete.side_effect = Exception("Storage delete error") + + with pytest.raises(Exception, match="Storage delete error"): + await item_namespace.delete("test_key") + + def test_type_annotations(self): + """Test that type annotations work correctly.""" + storage = MemoryStorage() + base_key = "type_test" + + # Test generic type specification + item_namespace_a: _ItemNamespace[MockStoreItem] = _ItemNamespace(base_key, storage, MockStoreItem) + item_namespace_b: _ItemNamespace[MockStoreItemB] = _ItemNamespace(base_key, storage, MockStoreItemB) + + # Verify the generic types are properly set + assert item_namespace_a._item_cls is MockStoreItem + assert item_namespace_b._item_cls is MockStoreItemB + + @pytest.mark.asyncio + async def test_edge_cases(self): + """Test edge cases and boundary conditions.""" + memory_storage = MemoryStorage() + base_key = "edge_test" + item_namespace = _ItemNamespace(base_key, memory_storage, MockStoreItem) + + # Test with very long key + long_key = "x" * 1000 + long_item = MockStoreItem({"data": "long_key_data"}) + await item_namespace.write(long_key, long_item) + result = await item_namespace.read(long_key) + assert result == long_item + + # Test with empty data + empty_data_item = MockStoreItem({}) + await item_namespace.write("empty_data", empty_data_item) + result = await item_namespace.read("empty_data") + assert result == empty_data_item + assert result.data == {} + + # Test overwriting existing key multiple times + key = "overwrite_test" + for i in range(3): + item = MockStoreItem({"version": i}) + await item_namespace.write(key, item) + result = await item_namespace.read(key) + assert result.data["version"] == i + + @pytest.mark.asyncio + async def test_docstring_examples(self): + """Test the examples that would be in docstrings.""" + memory_storage = MemoryStorage() + base_key = "auth/channel123/user456" + item_namespace = _ItemNamespace(base_key, memory_storage, MockStoreItem) + + # Example usage from docstrings + key = "oauth_handler" + + # Reading non-existent key returns None + result = await item_namespace.read(key) + assert result is None + + # Write an item + auth_data = MockStoreItem({ + "access_token": "abc123", + "refresh_token": "def456", + "expires_at": "2025-10-23T12:00:00Z", + "scopes": ["read", "write"] + }) + await item_namespace.write(key, auth_data) + + # Read the item back + retrieved_data = await item_namespace.read(key) + assert retrieved_data == auth_data + assert retrieved_data.data["access_token"] == "abc123" + + # Delete the item + await item_namespace.delete(key) + + # Verify deletion + result_after_delete = await item_namespace.read(key) + assert result_after_delete is None + + @pytest.mark.asyncio + async def test_storage_namespace_key_prefixing(self): + """Test that the underlying _StorageNamespace properly prefixes keys.""" + storage = AsyncMock() + base_key = "test_namespace" + item_namespace = _ItemNamespace(base_key, storage, MockStoreItem) + + key = "handler_id" + item = MockStoreItem({"data": "test"}) + + # Write operation should prefix the key + await item_namespace.write(key, item) + storage.write.assert_called_once_with({f"{base_key}:{key}": item}) + + # Read operation should prefix the key + storage.read.return_value = {f"{base_key}:{key}": item} + result = await item_namespace.read(key) + storage.read.assert_called_with([f"{base_key}:{key}"], target_cls=MockStoreItem) + + # Delete operation should prefix the key + await item_namespace.delete(key) + storage.delete.assert_called_with([f"{base_key}:{key}"]) + + @pytest.mark.asyncio + async def test_real_world_scenario(self): + """Test a real-world scenario with user authentication state.""" + memory_storage = MemoryStorage() + + # Create namespaces for different users and channels + user1_channel1 = _ItemNamespace("auth/channel1/user1", memory_storage, MockStoreItem) + user2_channel1 = _ItemNamespace("auth/channel1/user2", memory_storage, MockStoreItem) + user1_channel2 = _ItemNamespace("auth/channel2/user1", memory_storage, MockStoreItem) + + # Store authentication state for different oauth handlers + oauth_state_1 = MockStoreItem({ + "provider": "azure", + "state": "authenticated", + "access_token": "token1" + }) + + teams_state_1 = MockStoreItem({ + "provider": "teams", + "state": "authenticated", + "access_token": "token2" + }) + + oauth_state_2 = MockStoreItem({ + "provider": "azure", + "state": "pending", + "access_token": None + }) + + # Write states for different users/channels + await user1_channel1.write("oauth_handler", oauth_state_1) + await user1_channel1.write("teams_handler", teams_state_1) + await user2_channel1.write("oauth_handler", oauth_state_2) + await user1_channel2.write("oauth_handler", oauth_state_1) + + # Verify isolation and correct retrieval + assert await user1_channel1.read("oauth_handler") == oauth_state_1 + assert await user1_channel1.read("teams_handler") == teams_state_1 + assert await user2_channel1.read("oauth_handler") == oauth_state_2 + assert await user1_channel2.read("oauth_handler") == oauth_state_1 + + # Verify that each user/channel has isolated state + assert await user2_channel1.read("teams_handler") is None + assert await user1_channel2.read("teams_handler") is None + + # Update one user's state and verify others are unaffected + updated_oauth_state = MockStoreItem({ + "provider": "azure", + "state": "refreshed", + "access_token": "new_token1" + }) + await user1_channel1.write("oauth_handler", updated_oauth_state) + + assert await user1_channel1.read("oauth_handler") == updated_oauth_state + assert await user2_channel1.read("oauth_handler") == oauth_state_2 # Unchanged + assert await user1_channel2.read("oauth_handler") == oauth_state_1 # Unchanged \ No newline at end of file diff --git a/tests/hosting_core/storage/wrappers/test_memory_cache.py b/tests/hosting_core/storage/wrappers/test_memory_cache.py deleted file mode 100644 index e69de29b..00000000