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/__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 867b3aa6..00000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_oauth/_flow_storage_client.py +++ /dev/null @@ -1,93 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -from typing import Optional - -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 - - -# 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, - 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. - """ - - if not user_id or not channel_id: - raise ValueError( - "FlowStorageClient.__init__(): channel_id and user_id must be set." - ) - - 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: - """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 902b0dd4..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,9 +24,13 @@ _OAuthFlow, _FlowResponse, _FlowState, - _FlowStorageClient, _FlowStateTag, ) +from microsoft_agents.hosting.core.storage import ( + _ItemNamespace, + _Namespaces +) + from .._sign_in_response import _SignInResponse from ._authorization_handler import _AuthorizationHandler @@ -41,7 +45,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 +76,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 +94,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..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 @@ -11,7 +11,12 @@ from microsoft_agents.activity import Activity, TokenResponse from ...turn_context import TurnContext -from ...storage import Storage +from ...storage import ( + Storage, + _ItemNamespace, + _StorageNamespace, + _Namespaces +) from ...authorization import Connections from ..._oauth import _FlowStateTag from ..state import TurnState @@ -67,6 +72,7 @@ def __init__( self._storage = storage self._connection_manager = connection_manager + 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]] @@ -113,82 +119,11 @@ 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_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 f"auth:_SignInState:{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. @@ -228,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._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 not sign_in_state: # no existing sign-in state, create a new one sign_in_state = _SignInState(active_handler_id=auth_handler_id) @@ -243,7 +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._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 21c334cb..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,28 +1,41 @@ +from ._namespaces import _Namespaces from .store_item import StoreItem -from .storage import Storage, AsyncStorageBase +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, + TranscriptMemoryStore, +) +from ._wrappers import ( + _StorageNamespace + _ItemNamespace, + _ItemStorage, ) -from .transcript_store import TranscriptStore -from .transcript_file_store import FileTranscriptStore __all__ = [ + "_StorageNamespace", "StoreItem", "Storage", - "AsyncStorageBase", + "_AsyncStorageBase", "MemoryStorage", "TranscriptInfo", "TranscriptLogger", "ConsoleTranscriptLogger", "TranscriptLoggerMiddleware", "TranscriptStore", + "TranscriptMemoryStore", "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 new file mode 100644 index 00000000..6c705368 --- /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.""" + + 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/_transcript/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py new file mode 100644 index 00000000..df6f30c5 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_transcript/__init__.py @@ -0,0 +1,23 @@ +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 +from .transcript_memory_store import TranscriptMemoryStore + +__all__ = [ + "TranscriptInfo", + "TranscriptLogger", + "ConsoleTranscriptLogger", + "TranscriptLoggerMiddleware", + "FileTranscriptLogger", + "PagedResult", + "TranscriptStore", + "TranscriptMemoryStore", + "FileTranscriptStore", +] 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 d40bf092..be7592a3 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 @@ -235,29 +234,5 @@ def _get(obj: Any, *path: str) -> Optional[Any]: 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/_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 new file mode 100644 index 00000000..27015940 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/__init__.py @@ -0,0 +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 new file mode 100644 index 00000000..a498a966 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_item_namespace.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +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/_storage_namespace.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py new file mode 100644 index 00000000..fe8defbe --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_wrappers/_storage_namespace.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import TypeVar + +from microsoft_agents.activity 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) 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") 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 fdd4f6f8..79397f84 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): diff --git a/tests/hosting_core/storage/transcript/__init__.py b/tests/hosting_core/storage/transcript/__init__.py new file mode 100644 index 00000000..e69de29b 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 91% rename from tests/hosting_core/storage/test_transcript_logger_middleware.py rename to tests/hosting_core/storage/transcript/test_transcript_logger_middleware.py index 30ed2928..1cd4b38f 100644 --- a/tests/hosting_core/storage/test_transcript_logger_middleware.py +++ b/tests/hosting_core/storage/transcript/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/transcript/test_transcript_store_memory.py similarity index 99% rename from tests/hosting_core/storage/test_transcript_store_memory.py rename to tests/hosting_core/storage/transcript/test_transcript_store_memory.py index 691c3e02..f8fc1b59 100644 --- a/tests/hosting_core/storage/test_transcript_store_memory.py +++ b/tests/hosting_core/storage/transcript/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/__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_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_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 new file mode 100644 index 00000000..522dbf00 --- /dev/null +++ b/tests/hosting_core/storage/wrappers/test_storage_namespace.py @@ -0,0 +1,384 @@ +import pytest +from unittest.mock import Mock, AsyncMock + +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 + + +class TestStorageNamespace: + """Test cases for the _StorageNamespace class.""" + + 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 + + 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) + + 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): + """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 + 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( + ["test_namespace:missing1", "test_namespace:missing2"], + target_cls=MockStoreItem + ) + + @pytest.mark.asyncio + 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( + ["test_namespace:exists", "test_namespace:missing"], + target_cls=MockStoreItem + ) + + @pytest.mark.asyncio + 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 + 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_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) + + @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({}) + + @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"]) + + @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) + + @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([]) + + @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 + ) + + 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 + + @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 + + @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) + + 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