diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py index 7b91fff7..cbd7bb7a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/__init__.py @@ -24,32 +24,32 @@ from .conversation_resource_response import ConversationResourceResponse from .conversations_result import ConversationsResult from .expected_replies import ExpectedReplies -from .entity import Entity -from .ai_entity import ( +from .entity import ( + Entity, AIEntity, ClientCitation, ClientCitationAppearance, ClientCitationImage, ClientCitationIconName, + Mention, SensitivityUsageInfo, SensitivityPattern, - add_ai_to_activity, + GeoCoordinates, + Place, + Thing, ) from .error import Error from .error_response import ErrorResponse from .fact import Fact -from .geo_coordinates import GeoCoordinates from .hero_card import HeroCard from .inner_http_error import InnerHttpError from .invoke_response import InvokeResponse from .media_card import MediaCard from .media_event_value import MediaEventValue from .media_url import MediaUrl -from .mention import Mention from .message_reaction import MessageReaction from .oauth_card import OAuthCard from .paged_members_result import PagedMembersResult -from .place import Place from .receipt_card import ReceiptCard from .receipt_item import ReceiptItem from .resource_response import ResourceResponse @@ -57,7 +57,6 @@ from .signin_card import SigninCard from .suggested_actions import SuggestedActions from .text_highlight import TextHighlight -from .thing import Thing from .thumbnail_card import ThumbnailCard from .thumbnail_url import ThumbnailUrl from .token_exchange_invoke_request import TokenExchangeInvokeRequest @@ -92,7 +91,6 @@ from .conversation_update_types import ConversationUpdateTypes from .message_update_types import MessageUpdateTypes - from .channel_adapter_protocol import ChannelAdapterProtocol from .turn_context_protocol import TurnContextProtocol from ._load_configuration import load_configuration_from_env @@ -131,7 +129,6 @@ "ClientCitationIconName", "SensitivityUsageInfo", "SensitivityPattern", - "add_ai_to_activity", "Error", "ErrorResponse", "Fact", diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py new file mode 100644 index 00000000..025722a5 --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/_model_utils.py @@ -0,0 +1,61 @@ +from abc import ABC +from typing import Any, Callable + +from .agents_model import AgentsModel + + +class ModelFieldHelper(ABC): + """Base class for model field processing prior to initialization of an AgentsModel""" + + def process(self, key: str) -> dict[str, Any]: + """Takes the key in the destination object and returns a dictionary of new fields to add""" + raise NotImplementedError() + + +class SkipIf(ModelFieldHelper): + """Skip if the value meets the given condition.""" + + def __init__(self, value, skip_condition: Callable[[Any], bool]): + self.value = value + self._skip_condition = skip_condition + + def process(self, key: str) -> dict[str, Any]: + if self._skip_condition(self.value): + return {} + return {key: self.value} + + +class SkipNone(SkipIf): + """Skip if the value is None.""" + + def __init__(self, value): + super().__init__(value, lambda v: v is None) + + +def pick_model_dict(**kwargs): + """Processes a list of keyword arguments, using ModelFieldHelper subclasses to determine which fields to include in the final model. + + This function is useful for dynamically constructing models based on varying input data. + + Usage: + activity_dict = pick_model_dict(type="message", id="123", text=SkipNone(text_variable)) + activity = Activity.model_validate(activity_dict) + """ + + model_dict = {} + for key, value in kwargs.items(): + if not isinstance(value, ModelFieldHelper): + model_dict[key] = value + else: + model_dict.update(value.process(key)) + + return model_dict + + +def pick_model(model_class: type[AgentsModel], **kwargs) -> AgentsModel: + """Picks model fields from the given keyword arguments. + + Usage: + activity = pick_model(Activity, type="message", id="123", text=SkipNone(text_variable)) + """ + return model_class(**pick_model_dict(**kwargs)) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py index 3e328cce..89730568 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py @@ -5,16 +5,22 @@ from .activity_types import ActivityTypes from .channel_account import ChannelAccount from .conversation_account import ConversationAccount -from .mention import Mention from .message_reaction import MessageReaction from .resource_response import ResourceResponse from .suggested_actions import SuggestedActions from .attachment import Attachment -from .entity import Entity +from .entity import ( + Entity, + Mention, + AIEntity, + ClientCitation, + SensitivityUsageInfo, +) from .conversation_reference import ConversationReference from .text_highlight import TextHighlight from .semantic_action import SemanticAction from .agents_model import AgentsModel +from ._model_utils import pick_model, SkipNone from ._type_aliases import NonEmptyString @@ -390,32 +396,31 @@ def create_reply(self, text: str = None, locale: str = None): .. remarks:: The new activity sets up routing information based on this activity. """ - return Activity( + return pick_model( + Activity, type=ActivityTypes.message, timestamp=datetime.now(timezone.utc), - from_property=ChannelAccount( - id=self.recipient.id if self.recipient else None, - name=self.recipient.name if self.recipient else None, + from_property=SkipNone( + ChannelAccount.pick_properties(self.recipient, ["id", "name"]) ), - recipient=ChannelAccount( - id=self.from_property.id if self.from_property else None, - name=self.from_property.name if self.from_property else None, + recipient=SkipNone( + ChannelAccount.pick_properties(self.from_property, ["id", "name"]) ), reply_to_id=( - self.id + SkipNone(self.id) if type != ActivityTypes.conversation_update or self.channel_id not in ["directline", "webchat"] else None ), service_url=self.service_url, channel_id=self.channel_id, - conversation=ConversationAccount( - is_group=self.conversation.is_group, - id=self.conversation.id, - name=self.conversation.name, + conversation=SkipNone( + ConversationAccount.pick_properties( + self.conversation, ["is_group", "id", "name"] + ) ), text=text if text else "", - locale=locale if locale else self.locale, + locale=locale if locale else SkipNone(self.locale), attachments=[], entities=[], ) @@ -423,6 +428,7 @@ def create_reply(self, text: str = None, locale: str = None): def create_trace( self, name: str, value: object = None, value_type: str = None, label: str = None ): + # robrandao: TODO -> needs to handle Nones like create_reply """ Creates a new trace activity based on this activity. @@ -434,42 +440,42 @@ def create_trace( :returns: The new trace activity. """ if not value_type and value: - value_type = type(value) + value_type = type(value).__name__ - return Activity( + return pick_model( + Activity, type=ActivityTypes.trace, timestamp=datetime.now(timezone.utc), - from_property=ChannelAccount( - id=self.recipient.id if self.recipient else None, - name=self.recipient.name if self.recipient else None, + from_property=SkipNone( + ChannelAccount.pick_properties(self.recipient, ["id", "name"]) ), - recipient=ChannelAccount( - id=self.from_property.id if self.from_property else None, - name=self.from_property.name if self.from_property else None, + recipient=SkipNone( + ChannelAccount.pick_properties(self.from_property, ["id", "name"]) ), reply_to_id=( - self.id + SkipNone(self.id) # preserve unset if type != ActivityTypes.conversation_update or self.channel_id not in ["directline", "webchat"] else None ), service_url=self.service_url, channel_id=self.channel_id, - conversation=ConversationAccount( - is_group=self.conversation.is_group, - id=self.conversation.id, - name=self.conversation.name, + conversation=SkipNone( + ConversationAccount.pick_properties( + self.conversation, ["is_group", "id", "name"] + ) ), - name=name, - label=label, - value_type=value_type, - value=value, + name=SkipNone(name), + label=SkipNone(label), + value_type=SkipNone(value_type), + value=SkipNone(value), ).as_trace_activity() @staticmethod def create_trace_activity( name: str, value: object = None, value_type: str = None, label: str = None ): + # robrandao: TODO -> SkipNone """ Creates an instance of the :class:`Activity` class as a TraceActivity object. @@ -481,14 +487,15 @@ def create_trace_activity( :returns: The new trace activity. """ if not value_type and value: - value_type = type(value) + value_type = type(value).__name__ - return Activity( + return pick_model( + Activity, type=ActivityTypes.trace, name=name, - label=label, - value_type=value_type, - value=value, + label=SkipNone(label), + value_type=SkipNone(value_type), + value=SkipNone(value), ) @staticmethod @@ -506,10 +513,10 @@ def get_conversation_reference(self) -> ConversationReference: :returns: A conversation reference for the conversation that contains this activity. """ - - return ConversationReference( + return pick_model( + ConversationReference, activity_id=( - self.id + SkipNone(self.id) if self.type != ActivityTypes.conversation_update or self.channel_id not in ["directline", "webchat"] else None @@ -532,8 +539,9 @@ def get_mentions(self) -> list[Mention]: This method is defined on the :class:`Activity` class, but is only intended for use with a message activity, where the activity Activity.Type is set to ActivityTypes.Message. """ - _list = self.entities - return [x for x in _list if str(x.type).lower() == "mention"] + if not self.entities: + return [] + return [x for x in self.entities if x.type.lower() == "mention"] def get_reply_conversation_reference( self, reply: ResourceResponse @@ -596,7 +604,7 @@ def __is_activity(self, activity_type: str) -> bool: if self.type is None: return False - type_attribute = str(self.type).lower() + type_attribute = f"ActivityTypes.{str(self.type)}".lower() activity_type = str(activity_type).lower() result = type_attribute.startswith(activity_type) @@ -611,3 +619,32 @@ def __is_activity(self, activity_type: str) -> bool: ) return result + + def add_ai_metadata( + self, + citations: Optional[list[ClientCitation]] = None, + usage_info: Optional[SensitivityUsageInfo] = None, + ) -> None: + """ + Adds AI entity to an activity to indicate AI-generated content. + + Args: + activity: The activity to modify + citations: Optional list of citations + usage_info: Optional sensitivity usage information + """ + if citations: + ai_entity = AIEntity( + type="https://schema.org/Message", + schema_type="Message", + context="https://schema.org", + id="", + additional_type=["AIGeneratedContent"], + citation=citations, + usage_info=usage_info, + ) + + if self.entities is None: + self.entities = [] + + self.entities.append(ai_entity) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py index 32ac17be..75cf323a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/agents_model.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel @@ -16,3 +18,29 @@ def _serialize(self): return {k: v for k, v in self if k not in omit_if_empty and v is not None} """ + + @classmethod + def pick_properties(cls, original: AgentsModel, fields_to_copy=None, **kwargs): + """Picks properties from the original model and returns a new instance (of a possibly different AgentsModel) with those properties. + + This method preserves unset values. + + args: + original: The original model instance to copy properties from. If None, returns None. + fields_to_copy: The specific fields to copy. If None, all fields are copied. + **kwargs: Additional fields to include in the new instance. + """ + if not original: + return None + + if fields_to_copy is None: + fields_to_copy = original.model_fields_set + else: + fields_to_copy = original.model_fields_set & set(fields_to_copy) + + dest = {} + for field in fields_to_copy: + dest[field] = getattr(original, field) + + dest.update(kwargs) + return cls.model_validate(dest) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py index b6737b28..9c5bbbcc 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/animation_card.py @@ -42,7 +42,7 @@ class AnimationCard(AgentsModel): title: NonEmptyString = None subtitle: NonEmptyString = None - text: NonEmptyString = None + text: str = None image: ThumbnailUrl = None media: list[MediaUrl] = None buttons: list[CardAction] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py index b1dd041d..3ae0ccc5 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/audio_card.py @@ -42,7 +42,7 @@ class AudioCard(AgentsModel): title: NonEmptyString = None subtitle: NonEmptyString = None - text: NonEmptyString = None + text: str = None image: ThumbnailUrl = None media: list[MediaUrl] = None buttons: list[CardAction] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py index a7a76c57..f45fb206 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/basic_card.py @@ -24,7 +24,7 @@ class BasicCard(AgentsModel): title: NonEmptyString = None subtitle: NonEmptyString = None - text: NonEmptyString = None + text: str = None images: list[CardImage] = None buttons: list[CardAction] = None tap: CardAction = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py new file mode 100644 index 00000000..b35460d8 --- /dev/null +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/__init__.py @@ -0,0 +1,29 @@ +from .mention import Mention +from .entity import Entity +from .ai_entity import ( + ClientCitation, + ClientCitationAppearance, + ClientCitationImage, + ClientCitationIconName, + AIEntity, + SensitivityPattern, + SensitivityUsageInfo, +) +from .geo_coordinates import GeoCoordinates +from .place import Place +from .thing import Thing + +__all__ = [ + "Entity", + "AIEntity", + "ClientCitation", + "ClientCitationAppearance", + "ClientCitationImage", + "ClientCitationIconName", + "Mention", + "SensitivityUsageInfo", + "SensitivityPattern", + "GeoCoordinates", + "Place", + "Thing", +] diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/ai_entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py similarity index 77% rename from libraries/microsoft-agents-activity/microsoft_agents/activity/ai_entity.py rename to libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py index f7517f70..5995867e 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/ai_entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/ai_entity.py @@ -5,8 +5,7 @@ from typing import List, Optional, Union, Literal from dataclasses import dataclass -from .agents_model import AgentsModel -from .activity import Activity +from ..agents_model import AgentsModel from .entity import Entity @@ -110,33 +109,3 @@ class AIEntity(Entity): def __post_init__(self): if self.additional_type is None: self.additional_type = ["AIGeneratedContent"] - - -def add_ai_to_activity( - activity: Activity, - citations: Optional[List[ClientCitation]] = None, - usage_info: Optional[SensitivityUsageInfo] = None, -) -> None: - """ - Adds AI entity to an activity to indicate AI-generated content. - - Args: - activity: The activity to modify - citations: Optional list of citations - usage_info: Optional sensitivity usage information - """ - if citations: - ai_entity = AIEntity( - type="https://schema.org/Message", - schema_type="Message", - context="https://schema.org", - id="", - additional_type=["AIGeneratedContent"], - citation=citations, - usage_info=usage_info, - ) - - if activity.entities is None: - activity.entities = [] - - activity.entities.append(ai_entity) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py similarity index 87% rename from libraries/microsoft-agents-activity/microsoft_agents/activity/entity.py rename to libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py index 08bbf242..eee24060 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/entity.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/entity.py @@ -1,9 +1,11 @@ from typing import Any, Optional +from enum import Enum from pydantic import model_serializer, model_validator -from .agents_model import AgentsModel, ConfigDict from pydantic.alias_generators import to_camel, to_snake -from ._type_aliases import NonEmptyString + +from ..agents_model import AgentsModel, ConfigDict +from .._type_aliases import NonEmptyString class Entity(AgentsModel): @@ -15,7 +17,7 @@ class Entity(AgentsModel): model_config = ConfigDict(extra="allow") - type: NonEmptyString + type: str @property def additional_properties(self) -> dict[str, Any]: diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/geo_coordinates.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/geo_coordinates.py similarity index 90% rename from libraries/microsoft-agents-activity/microsoft_agents/activity/geo_coordinates.py rename to libraries/microsoft-agents-activity/microsoft_agents/activity/entity/geo_coordinates.py index 1c457f8e..54299781 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/geo_coordinates.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/geo_coordinates.py @@ -1,5 +1,5 @@ -from .agents_model import AgentsModel -from ._type_aliases import NonEmptyString +from ..agents_model import AgentsModel +from .._type_aliases import NonEmptyString class GeoCoordinates(AgentsModel): diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/mention.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/mention.py similarity index 70% rename from libraries/microsoft-agents-activity/microsoft_agents/activity/mention.py rename to libraries/microsoft-agents-activity/microsoft_agents/activity/entity/mention.py index 7bc18cce..17510039 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/mention.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/mention.py @@ -1,6 +1,8 @@ -from .channel_account import ChannelAccount +from typing import Literal + +from ..channel_account import ChannelAccount from .entity import Entity -from ._type_aliases import NonEmptyString +from .._type_aliases import NonEmptyString class Mention(Entity): @@ -15,5 +17,5 @@ class Mention(Entity): """ mentioned: ChannelAccount = None - text: NonEmptyString = None - type: NonEmptyString = None + text: str = None + type: Literal["mention"] = "mention" diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/place.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/place.py similarity index 90% rename from libraries/microsoft-agents-activity/microsoft_agents/activity/place.py rename to libraries/microsoft-agents-activity/microsoft_agents/activity/entity/place.py index cbf86e90..cdfeccdc 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/place.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/place.py @@ -1,5 +1,5 @@ -from .agents_model import AgentsModel -from ._type_aliases import NonEmptyString +from ..agents_model import AgentsModel +from .._type_aliases import NonEmptyString class Place(AgentsModel): diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/thing.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/thing.py similarity index 77% rename from libraries/microsoft-agents-activity/microsoft_agents/activity/thing.py rename to libraries/microsoft-agents-activity/microsoft_agents/activity/entity/thing.py index f2bb28c3..3c56aa0a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/thing.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/entity/thing.py @@ -1,5 +1,5 @@ -from .agents_model import AgentsModel -from ._type_aliases import NonEmptyString +from ..agents_model import AgentsModel +from .._type_aliases import NonEmptyString class Thing(AgentsModel): diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py index 1e3c8683..a2708a9c 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/hero_card.py @@ -24,7 +24,7 @@ class HeroCard(AgentsModel): title: NonEmptyString = None subtitle: NonEmptyString = None - text: NonEmptyString = None + text: str = None images: list[CardImage] = None buttons: list[CardAction] = None tap: CardAction = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py index c9f80983..89ccd12d 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/media_card.py @@ -42,7 +42,7 @@ class MediaCard(AgentsModel): title: NonEmptyString = None subtitle: NonEmptyString = None - text: NonEmptyString = None + text: str = None image: ThumbnailUrl = None media: list[MediaUrl] = None buttons: list[CardAction] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/oauth_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/oauth_card.py index e4fea08e..1505b7c3 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/oauth_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/oauth_card.py @@ -17,7 +17,7 @@ class OAuthCard(AgentsModel): :type buttons: list[~microsoft_agents.activity.CardAction] """ - text: NonEmptyString = None + text: str = None connection_name: NonEmptyString = None buttons: list[CardAction] = None token_exchange_resource: Optional[TokenExchangeResource] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/receipt_item.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/receipt_item.py index 645700e4..268fe226 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/receipt_item.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/receipt_item.py @@ -28,7 +28,7 @@ class ReceiptItem(AgentsModel): title: NonEmptyString = None subtitle: NonEmptyString = None - text: NonEmptyString = None + text: str = None image: CardImage = None price: NonEmptyString = None quantity: NonEmptyString = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/signin_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/signin_card.py index fac18931..9cb5197a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/signin_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/signin_card.py @@ -12,5 +12,5 @@ class SigninCard(AgentsModel): :type buttons: list[~microsoft_agents.activity.CardAction] """ - text: NonEmptyString = None + text: str = None buttons: list[CardAction] = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/text_highlight.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/text_highlight.py index 58115779..adcb83b6 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/text_highlight.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/text_highlight.py @@ -12,5 +12,5 @@ class TextHighlight(AgentsModel): :type occurrence: int """ - text: NonEmptyString + text: str occurrence: int diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py index 319b8646..f5c2e57a 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/thumbnail_card.py @@ -24,7 +24,7 @@ class ThumbnailCard(AgentsModel): title: NonEmptyString = None subtitle: NonEmptyString = None - text: NonEmptyString = None + text: str = None images: list[CardImage] = None buttons: list[CardAction] = None tap: CardAction = None diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py index 389c82e7..5a4d63f3 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/video_card.py @@ -42,7 +42,7 @@ class VideoCard(AgentsModel): title: NonEmptyString = None subtitle: NonEmptyString = None - text: NonEmptyString = None + text: str = None image: ThumbnailUrl = None media: list[MediaUrl] = None buttons: list[CardAction] = None diff --git a/libraries/microsoft-agents-hosting-core/tests/__init__.py b/libraries/microsoft-agents-activity/tests/__init__.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/tests/__init__.py rename to libraries/microsoft-agents-activity/tests/__init__.py diff --git a/libraries/microsoft-agents-activity/tests/activity_data/activity_test_data.py b/libraries/microsoft-agents-activity/tests/activity_data/activity_test_data.py new file mode 100644 index 00000000..65e49560 --- /dev/null +++ b/libraries/microsoft-agents-activity/tests/activity_data/activity_test_data.py @@ -0,0 +1,28 @@ +from microsoft_agents.activity import ( + Activity, + Attachment, + Mention, + GeoCoordinates, + Place, + Thing, + Entity, +) + + +class MyChannelData: + foo: str + bar: str + + +def GEN_HAS_CONTENT_DATA(): + return [ + (Activity(text="text"), True), + (Activity(summary="summary"), True), + (Activity(attachments=[Attachment()]), True), + (Activity(channel_data=MyChannelData()), True), + (Activity(), False), + ] + + +def GEN_TEST_CHANNEL_DATA(): + return [None, {}, MyChannelData()] diff --git a/libraries/microsoft-agents-activity/tests/activity_tools/__init__.py b/libraries/microsoft-agents-activity/tests/activity_tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-activity/tests/activity_tools/testing_activity.py b/libraries/microsoft-agents-activity/tests/activity_tools/testing_activity.py new file mode 100644 index 00000000..b0ae1293 --- /dev/null +++ b/libraries/microsoft-agents-activity/tests/activity_tools/testing_activity.py @@ -0,0 +1,45 @@ +from microsoft_agents.activity import Activity, ChannelAccount, ConversationAccount +from microsoft_agents.activity._model_utils import pick_model, SkipNone + + +def create_test_activity( + locale: str, create_recipient: bool = True, create_from: bool = True +) -> Activity: + properties = {"name": "Value"} + account1 = None + if create_from: + account1 = ChannelAccount( + id="ChannelAccount_Id_1", + name="ChannelAccount_Name_1", + properties=properties, + role="ChannelAccount_Role_1", + ) + + account2 = None + if create_recipient: + account2 = ChannelAccount( + id="ChannelAccount_Id_2", + name="ChannelAccount_Name_2", + properties=properties, + role="ChannelAccount_Role_2", + ) + + conversation_account = ConversationAccount( + conversation_type="a", + id="123", + is_group=True, + name="Name", + properties=properties, + role="ConversationAccount_Role", + ) + return pick_model( + Activity, + id="123", + from_property=SkipNone(account1), + recipient=SkipNone(account2), + conversation=conversation_account, + channel_id="ChannelId123", + locale=SkipNone(locale), + service_url="ServiceUrl123", + type="message", + ) diff --git a/libraries/microsoft-agents-activity/tests/activity_tools/testing_model_utils.py b/libraries/microsoft-agents-activity/tests/activity_tools/testing_model_utils.py new file mode 100644 index 00000000..17a23944 --- /dev/null +++ b/libraries/microsoft-agents-activity/tests/activity_tools/testing_model_utils.py @@ -0,0 +1,39 @@ +from abc import ABC +from typing import Any, Callable + +from microsoft_agents.activity import ( + AgentsModel, + Activity, +) +from microsoft_agents.activity._model_utils import ( + ModelFieldHelper, + SkipIf, + pick_model_dict, + pick_model, +) + + +class SkipFalse(SkipIf): + def __init__(self, value): + super().__init__(value, lambda x: not bool(x)) + + +class SkipEmpty(SkipIf): + def __init__(self, value): + super().__init__(value, lambda x: len(x) == 0) + + +class PickField(ModelFieldHelper): + def __init__(self, original, key_in_original=None): + assert isinstance(original, AgentsModel) + self.original = original + self.key_in_original = key_in_original + + def process(self, key): + + target_key = self.key_in_original or key + + if target_key in self.original.model_fields_set: + return {key: getattr(self.original, target_key)} + else: + return {} diff --git a/libraries/microsoft-agents-activity/tests/test_activity.py b/libraries/microsoft-agents-activity/tests/test_activity.py new file mode 100644 index 00000000..e18b4aad --- /dev/null +++ b/libraries/microsoft-agents-activity/tests/test_activity.py @@ -0,0 +1,371 @@ +from microsoft_agents.activity.entity import mention +import pytest + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + Entity, + Mention, + ResourceResponse, + ChannelAccount, + ConversationAccount, + ConversationReference, + DeliveryModes, + Attachment, + GeoCoordinates, + AIEntity, + Place, + Thing, +) + +from .activity_data.activity_test_data import MyChannelData +from .activity_tools.testing_activity import create_test_activity + + +def helper_validate_recipient_and_from( + activity: Activity, create_recipient: bool, create_from: bool +): + if create_recipient: + assert activity.from_property.id == "ChannelAccount_Id_2" + assert activity.from_property.name == "ChannelAccount_Name_2" + else: + assert activity.from_property.id is None + assert activity.from_property.name is None + + if create_from: + assert activity.recipient.id == "ChannelAccount_Id_1" + assert activity.recipient.name == "ChannelAccount_Name_1" + else: + assert activity.recipient.id is None + assert activity.recipient.name is None + + +def helper_get_expected_try_get_channel_data_result(channel_data) -> bool: + return isinstance(channel_data, dict) or isinstance(channel_data, MyChannelData) + + +class TestActivityConversationOps: + + @pytest.fixture + def activity(self): + return create_test_activity("en-us") + + def test_get_conversation_reference(self, activity): + conversation_reference = activity.get_conversation_reference() + + assert activity.id == conversation_reference.activity_id + assert activity.from_property.id == conversation_reference.user.id + assert activity.recipient.id == conversation_reference.agent.id + assert activity.conversation.id == conversation_reference.conversation.id + assert activity.channel_id == conversation_reference.channel_id + assert activity.locale == conversation_reference.locale + assert activity.service_url == conversation_reference.service_url + + def test_get_reply_conversation_reference(self, activity): + reply = ResourceResponse(id="1234") + conversation_reference = activity.get_reply_conversation_reference(reply) + + assert reply.id == conversation_reference.activity_id + assert activity.from_property.id == conversation_reference.user.id + assert activity.recipient.id == conversation_reference.agent.id + assert activity.conversation.id == conversation_reference.conversation.id + assert activity.channel_id == conversation_reference.channel_id + assert activity.locale == conversation_reference.locale + assert activity.service_url == conversation_reference.service_url + + def remove_recipient_mention_for_teams(self, activity): + activity.text = "firstName lastName\n" + expected_stripped_name = "lastName" + + mention = Mention( + mentioned=ChannelAccount(id=activity.recipient.id, name="firstName"), + text=None, + ) + lst = [] + + output = mention.model_dump() + entity = Entity(**output) + + lst.append(entity) + activity.entities = lst + + stripped_activity_text = activity.remove_recipient_mention() + assert stripped_activity_text == expected_stripped_name + + def remove_recipient_mention_for_non_teams_scenario(self, activity): + activity.text = "firstName lastName\n" + expected_stripped_name = "lastName" + + mention = Mention( + ChannelAccount(id=activity.recipient.id, name="firstName"), + text="firstName", + ) + lst = [] + + output = mention.model_dump() + entity = Entity(**output) + + lst.append(entity) + activity.entities = lst + + stripped_activity_text = activity.remove_recipient_mention() + assert stripped_activity_text == expected_stripped_name + + def test_apply_conversation_reference_is_incoming(self): + activity = create_test_activity("en-uS") # on purpose + conversation_reference = ConversationReference( + channel_id="cr_123", + service_url="cr_serviceUrl", + conversation=ConversationAccount(id="cr_456"), + user=ChannelAccount(id="cr_abc"), + agent=ChannelAccount(id="cr_def"), + activity_id="cr_12345", + locale="en-us", + # delivery_mode = DeliveryModes.expect_replies + ) + + activity_to_send = activity.apply_conversation_reference( + conversation_reference, is_incoming=True + ) + conversation_reference = activity_to_send.get_conversation_reference() + + assert conversation_reference.channel_id == activity.channel_id + assert conversation_reference.service_url == activity.service_url + assert conversation_reference.conversation.id == activity.conversation.id + # assert conversation_reference.delivery_mode == activity.delivery_mode robrandao: TODO + assert conversation_reference.user.id == activity.from_property.id + assert conversation_reference.agent.id == activity.recipient.id + assert conversation_reference.activity_id == activity.id + assert activity.locale == activity_to_send.locale + + @pytest.mark.parametrize("locale", ["EN-US", "en-uS"]) + def test_apply_conversation_reference(self, locale): + activity = create_test_activity(locale) + conversation_reference = ConversationReference( + channel_id="123", + service_url="serviceUrl", + conversation=ConversationAccount(id="456"), + user=ChannelAccount(id="abc"), + agent=ChannelAccount(id="def"), + activity_id="12345", + locale="en-us", + ) + + activity_to_send = activity.apply_conversation_reference( + conversation_reference, is_incoming=False + ) + + assert conversation_reference.channel_id == activity.channel_id + assert conversation_reference.service_url == activity.service_url + assert conversation_reference.conversation.id == activity.conversation.id + + assert conversation_reference.agent.id == activity.from_property.id + assert conversation_reference.user.id == activity.recipient.id + + if locale is None: + assert conversation_reference.locale == activity_to_send.locale + else: + assert activity.locale == activity_to_send.locale + + @pytest.mark.parametrize( + "value, value_type, create_recipient, create_from, label", + [ + ["myValue", None, False, False, None], + [None, None, False, False, None], + [None, "myValueType", False, False, None], + [None, None, True, False, None], + [None, None, False, True, "testLabel"], + ], + ) + def test_create_trace( + self, value, value_type, create_recipient, create_from, label + ): + activity = create_test_activity("en-us", create_recipient, create_from) + trace = activity.create_trace("test", value, value_type, label) + + assert trace is not None + assert trace.type == ActivityTypes.trace + if value_type: + assert trace.value_type == value_type + elif value: + assert trace.value_type == type(value).__name__ + else: + assert trace.value_type is None + assert trace.label == label + assert trace.name == "test" + + @pytest.mark.parametrize( + "activity_type, activity_type_name", + [ + (ActivityTypes.end_of_conversation, "end_of_conversation"), + (ActivityTypes.event, "event"), + (ActivityTypes.handoff, "handoff"), + (ActivityTypes.invoke, "invoke"), + (ActivityTypes.message, "message"), + (ActivityTypes.typing, "typing"), + ], + ) + def test_can_create_activities(self, activity_type, activity_type_name): + create_activity_method = getattr( + Activity, f"create_{activity_type_name}_activity" + ) + activity = create_activity_method() + expected_activity_type = activity_type + + assert activity is not None + assert activity.type == expected_activity_type + + if expected_activity_type == ActivityTypes.message: + assert activity.attachments is None + assert activity.entities is None + + @pytest.mark.parametrize( + "name, value_type, value, label", + [["TestTrace", "NoneType", None, None], ["TestTrace", None, "TestValue", None]], + ) + def test_create_trace_activity(self, name, value_type, value, label): + activity = Activity.create_trace_activity(name, value, value_type, label) + + assert activity is not None + assert activity.type == ActivityTypes.trace + assert activity.name == name + assert activity.value_type == type(value).__name__ + assert activity.value == value + assert activity.label == label + + @pytest.mark.parametrize( + "activity_locale, text, create_recipient, create_from, create_reply_locale", + [ + ["en-uS", "response", False, True, None], + ["en-uS", "response", False, False, None], + [None, "", True, False, "en-us"], + [None, None, True, True, None], + ], + ) + def test_can_create_reply_activity( + self, activity_locale, text, create_recipient, create_from, create_reply_locale + ): + activity = create_test_activity(activity_locale, create_recipient, create_from) + reply = activity.create_reply(text, locale=create_reply_locale) + + assert reply is not None + assert reply.type == ActivityTypes.message + assert reply.reply_to_id == "123" + assert reply.service_url == "ServiceUrl123" + assert reply.channel_id == "ChannelId123" + assert reply.text == text or reply.text == "" + assert reply.locale == activity_locale or create_reply_locale + + if create_recipient: + assert reply.from_property.id == "ChannelAccount_Id_2" + assert reply.from_property.name == "ChannelAccount_Name_2" + else: + assert reply.from_property is None + + if create_from: + assert reply.recipient.id == "ChannelAccount_Id_1" + assert reply.recipient.name == "ChannelAccount_Name_1" + else: + assert reply.recipient is None + + @pytest.fixture(params=[None, {}, MyChannelData()]) + def channel_data(self, request): + return request.param + + @pytest.mark.parametrize( + "activity, expected", + [ + [Activity(type=ActivityTypes.message, text="Hello"), True], + [Activity(type=ActivityTypes.message, text=" \n \t "), False], + [Activity(type=ActivityTypes.message, text=" "), False], + [ + Activity(type=ActivityTypes.message, attachments=[], summary="Summary"), + True, + ], + [Activity(type=ActivityTypes.message, text=" ", summary="\t"), False], + [Activity(type=ActivityTypes.message, summary="\t"), False], + [ + Activity( + type=ActivityTypes.message, + text="\n", + summary="\n", + attachments=[Attachment(content_type="123")], + ), + True, + ], + [ + Activity( + type=ActivityTypes.message, text="\n", summary="\n", attachments=[] + ), + False, + ], + [ + Activity( + type=ActivityTypes.message, + text="\n", + summary="\t", + attachments=[], + channel_data=MyChannelData(), + ), + True, + ], + [ + Activity( + type=ActivityTypes.message, + text="\n", + summary=" wow ", + attachments=[], + channel_data=MyChannelData(), + ), + True, + ], + [ + Activity( + type=ActivityTypes.message, + text="huh ", + summary="\t", + attachments=[], + channel_data=MyChannelData(), + ), + True, + ], + ], + ) + def test_has_content(self, activity, expected): + assert activity.has_content() == expected + + @pytest.mark.parametrize( + "service_url, expected", + [ + ["https://localhost", False], + ["microsoft.com", True], + ["http", False], + ["HTTP", False], + ["api://123", True], + [" ", True], + ], + ) + def test_is_from_streaming_connection(self, service_url, expected): + activity = Activity(type="message", service_url=service_url) + assert activity.is_from_streaming_connection() == expected + + def test_serialize_basic(self, activity): + activity_copy = Activity( + **activity.model_dump(mode="json", exclude_unset=True, by_alias=True) + ) + assert activity_copy == activity + + def test_get_mentions(self): + activity = Activity( + type="message", + entities=[ + Mention(text="Hello"), + Entity(type="other"), + Entity(type="mention", text="Another mention"), + ], + ) + mentions = activity.get_mentions() + assert mentions == [ + Mention(text="Hello"), + Entity(type="mention", text="Another mention"), + ] diff --git a/libraries/microsoft-agents-activity/tests/test_activity_types.py b/libraries/microsoft-agents-activity/tests/test_activity_types.py deleted file mode 100644 index 201975fc..00000000 --- a/libraries/microsoft-agents-activity/tests/test_activity_types.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_placeholder(): - pass diff --git a/libraries/microsoft-agents-activity/tests/test_token_response.py b/libraries/microsoft-agents-activity/tests/test_token_response.py new file mode 100644 index 00000000..d35fe5c7 --- /dev/null +++ b/libraries/microsoft-agents-activity/tests/test_token_response.py @@ -0,0 +1,24 @@ +import pytest +from microsoft_agents.activity import TokenResponse + + +def test_token_response_model_token_enforcement(): + with pytest.raises(Exception): + TokenResponse(token="") + with pytest.raises(Exception): + TokenResponse(token=None) + + +@pytest.mark.parametrize( + "token_response", [TokenResponse(), TokenResponse(expiration="expiration")] +) +def test_token_response_bool_op_false(token_response): + assert not bool(token_response) + + +@pytest.mark.parametrize( + "token_response", + [TokenResponse(token="token"), TokenResponse(token="a", expiration="a")], +) +def test_token_response_bool_op_true(token_response): + assert bool(token_response) diff --git a/libraries/microsoft-agents-activity/tests/test_tools.py b/libraries/microsoft-agents-activity/tests/test_tools.py new file mode 100644 index 00000000..47427b69 --- /dev/null +++ b/libraries/microsoft-agents-activity/tests/test_tools.py @@ -0,0 +1,116 @@ +import pytest + +from microsoft_agents.activity import Activity, ChannelAccount +from microsoft_agents.activity._model_utils import ( + ModelFieldHelper, + SkipNone, + SkipIf, + pick_model, + pick_model_dict, +) + +from .activity_tools.testing_model_utils import SkipFalse, SkipEmpty, PickField + + +class TestModelUtils: + + def test_skip_if(self): + field = SkipIf("foo", lambda v: v == "foo") + assert field.process("key") == {} + + @pytest.mark.parametrize( + "value, expected", + [ + [None, {}], + [42, {"field": 42}], + ["foo", {"field": "foo"}], + ], + ) + def test_skip_none(self, value, expected): + field = SkipNone(value) + assert field.process("field") == expected + + @pytest.mark.parametrize("value", [0, None, [], {}, False, ""]) + def test_skip_false_with_falsy_value(self, value): + field = SkipFalse(value) + assert field.process("key") == {} + + @pytest.mark.parametrize("value", [2, [1, 2, 3], "aha"]) + def test_skip_false_with_truthy_value(self, value): + field = SkipFalse(value) + assert field.process("key") == {"key": value} + + @pytest.mark.parametrize("value", ["", [], set(), {}, tuple()]) + def test_skip_empty_with_empty_value(self, value): + field = SkipEmpty(value) + assert field.process("key") == {} + + @pytest.mark.parametrize("value", ["wow", [2], set("a"), {"a": "b"}]) + def test_skip_empty_with_nonempty_value(self, value): + field = SkipEmpty(value) + assert field.process("key") == {"key": value} + + def test_pick_model(self, mocker): + recipient = ChannelAccount(id="123", name="foo") + activity = pick_model( + Activity, + type="message", + id=SkipNone(None), + from_property=pick_model( + ChannelAccount, + id=PickField(recipient), + aad_object_id=PickField(recipient), + ), + recipient=pick_model( + ChannelAccount, + id=PickField(recipient), + name=PickField(recipient), + role=PickField(recipient), + ), + text=PickField(recipient, "name"), + ) + expected = Activity( + type="message", + from_property=ChannelAccount(id="123"), + recipient=ChannelAccount(id="123", name="foo"), + text="foo", + ) + + assert activity == expected + assert "id" not in activity.model_fields_set + assert "aad_object_id" not in activity.from_property.model_fields_set + assert "role" not in activity.recipient.model_fields_set + assert "text" in activity.model_fields_set + + def test_pick_model_dict(self): + class Foo(ModelFieldHelper): + def process(self, key): + return {key: "bar"} + + class Bar(ModelFieldHelper): + def process(self, key): + return {"bar": "bar"} + + foo = Foo() + result = foo.process("foo") + assert result == {"foo": "bar"} + + res = pick_model_dict( + a=Foo(), + b=SkipNone("bar"), + c=SkipIf("baz", lambda v: v == "baz"), + d=Foo(), + e=None, + f=42, + bar=7, + g=Bar(), + ) + + assert res == { + "a": "bar", + "b": "bar", + "d": "bar", + "e": None, + "f": 42, + "bar": "bar", + } diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/streaming_response.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/streaming_response.py index ef01e870..1d155672 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/streaming_response.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/app/streaming/streaming_response.py @@ -14,7 +14,7 @@ ClientCitation, DeliveryModes, SensitivityUsageInfo, - add_ai_to_activity, + add_ai_metadata, ) if TYPE_CHECKING: @@ -401,7 +401,7 @@ async def _send_activity(self, activity: Activity) -> None: streaminfo_entity.feedback_loop_enabled = self._enable_feedback_loop # Add in Generated by AI if self._enable_generated_by_ai_label: - add_ai_to_activity(activity, self._citations, self._sensitivity_label) + add_ai_metadata(activity, self._citations, self._sensitivity_label) # Send activity response = await self._context.send_activity(activity) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage_test_utils.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_storage_test_utils.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/storage_test_utils.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/_storage_test_utils.py diff --git a/libraries/microsoft-agents-hosting-core/tests/core_tools/__init__.py b/libraries/microsoft-agents-hosting-core/tests/core_tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-hosting-core/tests/tools/mock_user_token_client.py b/libraries/microsoft-agents-hosting-core/tests/core_tools/mock_user_token_client.py similarity index 96% rename from libraries/microsoft-agents-hosting-core/tests/tools/mock_user_token_client.py rename to libraries/microsoft-agents-hosting-core/tests/core_tools/mock_user_token_client.py index a0ba0ace..a88a81d5 100644 --- a/libraries/microsoft-agents-hosting-core/tests/tools/mock_user_token_client.py +++ b/libraries/microsoft-agents-hosting-core/tests/core_tools/mock_user_token_client.py @@ -1,87 +1,87 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -import asyncio -import uuid -from datetime import datetime, timezone -from typing import Callable, List, Optional, Awaitable -from collections import deque - -from microsoft_agents.hosting.core.authorization import ClaimsIdentity -from microsoft_agents.activity import ( - Activity, - ActivityTypes, - ChannelAccount, - ConversationAccount, - ConversationReference, - Channels, - ResourceResponse, - RoleTypes, - InvokeResponse, -) -from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter -from microsoft_agents.hosting.core.turn_context import TurnContext -from microsoft_agents.hosting.core.connector import UserTokenClient - -AgentCallbackHandler = Callable[[TurnContext], Awaitable] - - -# patch userTokenclient constructor -class MockUserTokenClient(UserTokenClient): - """A mock user token client for testing.""" - - def __init__(self): - self._store = {} - self._exchange_store = {} - self._throw_on_exchange = {} - - def add_user_token( - self, - connection_name: str, - channel_id: str, - user_id: str, - token: str, - magic_code: str = None, - ): - """Add a token for a user that can be retrieved during testing.""" - key = self._get_key(connection_name, channel_id, user_id) - self._store[key] = (token, magic_code) - - def add_exchangeable_token( - self, - connection_name: str, - channel_id: str, - user_id: str, - exchangeable_item: str, - token: str, - ): - """Add an exchangeable token for a user that can be exchanged during testing.""" - key = self._get_exchange_key( - connection_name, channel_id, user_id, exchangeable_item - ) - self._exchange_store[key] = token - - def throw_on_exchange_request( - self, - connection_name: str, - channel_id: str, - user_id: str, - exchangeable_item: str, - ): - """Add an instruction to throw an exception during exchange requests.""" - key = self._get_exchange_key( - connection_name, channel_id, user_id, exchangeable_item - ) - self._throw_on_exchange[key] = True - - def _get_key(self, connection_name: str, channel_id: str, user_id: str) -> str: - return f"{connection_name}:{channel_id}:{user_id}" - - def _get_exchange_key( - self, - connection_name: str, - channel_id: str, - user_id: str, - exchangeable_item: str, - ) -> str: - return f"{connection_name}:{channel_id}:{user_id}:{exchangeable_item}" +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import uuid +from datetime import datetime, timezone +from typing import Callable, List, Optional, Awaitable +from collections import deque + +from microsoft_agents.hosting.core.authorization import ClaimsIdentity +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + ConversationReference, + Channels, + ResourceResponse, + RoleTypes, + InvokeResponse, +) +from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter +from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents.hosting.core.connector import UserTokenClient + +AgentCallbackHandler = Callable[[TurnContext], Awaitable] + + +# patch userTokenclient constructor +class MockUserTokenClient(UserTokenClient): + """A mock user token client for testing.""" + + def __init__(self): + self._store = {} + self._exchange_store = {} + self._throw_on_exchange = {} + + def add_user_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + token: str, + magic_code: str = None, + ): + """Add a token for a user that can be retrieved during testing.""" + key = self._get_key(connection_name, channel_id, user_id) + self._store[key] = (token, magic_code) + + def add_exchangeable_token( + self, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + token: str, + ): + """Add an exchangeable token for a user that can be exchanged during testing.""" + key = self._get_exchange_key( + connection_name, channel_id, user_id, exchangeable_item + ) + self._exchange_store[key] = token + + def throw_on_exchange_request( + self, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + ): + """Add an instruction to throw an exception during exchange requests.""" + key = self._get_exchange_key( + connection_name, channel_id, user_id, exchangeable_item + ) + self._throw_on_exchange[key] = True + + def _get_key(self, connection_name: str, channel_id: str, user_id: str) -> str: + return f"{connection_name}:{channel_id}:{user_id}" + + def _get_exchange_key( + self, + connection_name: str, + channel_id: str, + user_id: str, + exchangeable_item: str, + ) -> str: + return f"{connection_name}:{channel_id}:{user_id}:{exchangeable_item}" diff --git a/libraries/microsoft-agents-hosting-core/tests/tools/testing_adapter.py b/libraries/microsoft-agents-hosting-core/tests/core_tools/testing_adapter.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/tests/tools/testing_adapter.py rename to libraries/microsoft-agents-hosting-core/tests/core_tools/testing_adapter.py diff --git a/libraries/microsoft-agents-hosting-core/tests/tools/testing_authorization.py b/libraries/microsoft-agents-hosting-core/tests/core_tools/testing_authorization.py similarity index 97% rename from libraries/microsoft-agents-hosting-core/tests/tools/testing_authorization.py rename to libraries/microsoft-agents-hosting-core/tests/core_tools/testing_authorization.py index 5c1f50fc..c0f0913a 100644 --- a/libraries/microsoft-agents-hosting-core/tests/tools/testing_authorization.py +++ b/libraries/microsoft-agents-hosting-core/tests/core_tools/testing_authorization.py @@ -1,248 +1,248 @@ -""" -Testing utilities for authorization functionality - -This module provides mock implementations and helper classes for testing authorization, -authentication, and token management scenarios. It includes test doubles for token -providers, connection managers, and authorization handlers that can be configured -to simulate various authentication states and flow conditions. -""" - -from microsoft_agents.hosting.core import ( - Connections, - AccessTokenProviderBase, - AuthHandler, - Authorization, - MemoryStorage, - OAuthFlow, -) -from typing import Dict, Union -from microsoft_agents.hosting.core.authorization.agent_auth_configuration import ( - AgentAuthConfiguration, -) -from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity - -from microsoft_agents.activity import TokenResponse - -from unittest.mock import Mock, AsyncMock - - -def create_test_auth_handler( - name: str, obo: bool = False, title: str = None, text: str = None -): - """ - Creates a test AuthHandler instance with standardized connection names. - - This helper function simplifies the creation of AuthHandler objects for testing - by automatically generating connection names based on the provided name and - optionally including On-Behalf-Of (OBO) connection configuration. - - Args: - name: Base name for the auth handler, used to generate connection names - obo: Whether to include On-Behalf-Of connection configuration - title: Optional title for the auth handler - text: Optional descriptive text for the auth handler - - Returns: - AuthHandler: Configured auth handler instance with test-friendly connection names - """ - return AuthHandler( - name, - abs_oauth_connection_name=f"{name}-abs-connection", - obo_connection_name=f"{name}-obo-connection" if obo else None, - title=title, - text=text, - ) - - -class TestingTokenProvider(AccessTokenProviderBase): - """ - Access token provider for unit tests. - - This test double simulates an access token provider that returns predictable - token values based on the provider name. It implements both standard token - acquisition and On-Behalf-Of (OBO) token flows for comprehensive testing - of authentication scenarios. - """ - - def __init__(self, name: str): - """ - Initialize the testing token provider. - - Args: - name: Identifier used to generate predictable token values - """ - self.name = name - - async def get_access_token( - self, resource_url: str, scopes: list[str], force_refresh: bool = False - ) -> str: - """ - Get an access token for the specified resource and scopes. - - Returns a predictable token string based on the provider name for testing. - - Args: (unused in test implementation) - resource_url: URL of the resource requiring authentication - scopes: List of OAuth scopes requested - force_refresh: Whether to force token refresh - - Returns: - str: Test token in format "{name}-token" - """ - return f"{self.name}-token" - - async def aquire_token_on_behalf_of( - self, scopes: list[str], user_assertion: str - ) -> str: - """ - Acquire a token on behalf of another user (OBO flow). - - Returns a predictable OBO token string for testing scenarios involving - delegated permissions and token exchange. - - Args: (unused in test implementation) - scopes: List of OAuth scopes requested for the OBO token - user_assertion: JWT token representing the user's identity - - Returns: - str: Test OBO token in format "{name}-obo-token" - """ - return f"{self.name}-obo-token" - - -class TestingConnectionManager(Connections): - """ - Connection manager for unit tests. - - This test double provides a simplified connection management interface that - returns TestingTokenProvider instances for all connection requests. It enables - testing of authorization flows without requiring actual OAuth configurations - or external authentication services. - """ - - def get_connection(self, connection_name: str) -> AccessTokenProviderBase: - """ - Get a token provider for the specified connection name. - - Args: - connection_name: Name of the OAuth connection - - Returns: - AccessTokenProviderBase: TestingTokenProvider configured with the connection name - """ - return TestingTokenProvider(connection_name) - - def get_default_connection(self) -> AccessTokenProviderBase: - """ - Get the default token provider. - - Returns: - AccessTokenProviderBase: TestingTokenProvider configured with "default" name - """ - return TestingTokenProvider("default") - - def get_token_provider( - self, claims_identity: ClaimsIdentity, service_url: str - ) -> AccessTokenProviderBase: - """ - Get a token provider based on claims identity and service URL. - - In this test implementation, returns the default connection regardless - of the provided parameters. - - Args: (unused in test implementation) - claims_identity: User's claims and identity information - service_url: URL of the service requiring authentication - - Returns: - AccessTokenProviderBase: The default TestingTokenProvider - """ - return self.get_default_connection() - - def get_default_connection_configuration(self) -> AgentAuthConfiguration: - """ - Get the default authentication configuration. - - Returns: - AgentAuthConfiguration: Empty configuration suitable for testing - """ - return AgentAuthConfiguration() - - -class TestingAuthorization(Authorization): - """ - Authorization system for comprehensive unit testing. - - This test double extends the Authorization class to provide a fully mocked - authorization environment suitable for testing various authentication scenarios. - It automatically configures auth handlers with mock OAuth flows that can simulate - different states like successful authentication, failed sign-in, or in-progress flows. - """ - - def __init__( - self, - auth_handlers: Dict[str, AuthHandler], - token: Union[str, None] = "default", - flow_started=False, - sign_in_failed=False, - ): - """ - Initialize the testing authorization system. - - Sets up a complete test authorization environment with memory storage, - test connection manager, and configures all provided auth handlers with - mock OAuth flows. - - Args: - auth_handlers: Dictionary mapping handler names to AuthHandler instances - token: Token value to use in mock responses. "default" uses auto-generated - tokens, None simulates no token available, or provide custom jwt token string - flow_started: Simulate OAuth flows that have already started - sign_in_failed: Simulate failed sign-in attempts - """ - # Initialize with test-friendly components - storage = MemoryStorage() - connection_manager = TestingConnectionManager() - super().__init__( - storage=storage, - auth_handlers=auth_handlers, - connection_manager=connection_manager, - service_url="a", - ) - - # Configure each auth handler with mock OAuth flow behavior - for auth_handler in self._auth_handlers.values(): - # Create default token response for this auth handler - default_token = TokenResponse( - connection_name=auth_handler.abs_oauth_connection_name, - token=f"{auth_handler.abs_oauth_connection_name}-token", - ) - - # Determine token response based on configuration - if token == "default": - token_response = default_token - elif token: - token_response = TokenResponse( - connection_name=auth_handler.abs_oauth_connection_name, - token=token, - ) - else: - token_response = None - - # Mock the OAuth flow with configurable behavior - auth_handler.flow = Mock( - get_user_token=AsyncMock(return_value=token_response), - _get_flow_state=AsyncMock( - # sign-in failed requires flow to be started - return_value=oauth_flow.FlowState( - flow_started=(flow_started or sign_in_failed) - ) - ), - begin_flow=AsyncMock(return_value=default_token), - # Mock flow continuation with optional failure simulation - continue_flow=AsyncMock( - return_value=None if sign_in_failed else default_token - ), - ) - - auth_handler.flow.flow_state = None +""" +Testing utilities for authorization functionality + +This module provides mock implementations and helper classes for testing authorization, +authentication, and token management scenarios. It includes test doubles for token +providers, connection managers, and authorization handlers that can be configured +to simulate various authentication states and flow conditions. +""" + +from microsoft_agents.hosting.core import ( + Connections, + AccessTokenProviderBase, + AuthHandler, + Authorization, + MemoryStorage, + OAuthFlow, +) +from typing import Dict, Union +from microsoft_agents.hosting.core.authorization.agent_auth_configuration import ( + AgentAuthConfiguration, +) +from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity + +from microsoft_agents.activity import TokenResponse + +from unittest.mock import Mock, AsyncMock + + +def create_test_auth_handler( + name: str, obo: bool = False, title: str = None, text: str = None +): + """ + Creates a test AuthHandler instance with standardized connection names. + + This helper function simplifies the creation of AuthHandler objects for testing + by automatically generating connection names based on the provided name and + optionally including On-Behalf-Of (OBO) connection configuration. + + Args: + name: Base name for the auth handler, used to generate connection names + obo: Whether to include On-Behalf-Of connection configuration + title: Optional title for the auth handler + text: Optional descriptive text for the auth handler + + Returns: + AuthHandler: Configured auth handler instance with test-friendly connection names + """ + return AuthHandler( + name, + abs_oauth_connection_name=f"{name}-abs-connection", + obo_connection_name=f"{name}-obo-connection" if obo else None, + title=title, + text=text, + ) + + +class TestingTokenProvider(AccessTokenProviderBase): + """ + Access token provider for unit tests. + + This test double simulates an access token provider that returns predictable + token values based on the provider name. It implements both standard token + acquisition and On-Behalf-Of (OBO) token flows for comprehensive testing + of authentication scenarios. + """ + + def __init__(self, name: str): + """ + Initialize the testing token provider. + + Args: + name: Identifier used to generate predictable token values + """ + self.name = name + + async def get_access_token( + self, resource_url: str, scopes: list[str], force_refresh: bool = False + ) -> str: + """ + Get an access token for the specified resource and scopes. + + Returns a predictable token string based on the provider name for testing. + + Args: (unused in test implementation) + resource_url: URL of the resource requiring authentication + scopes: List of OAuth scopes requested + force_refresh: Whether to force token refresh + + Returns: + str: Test token in format "{name}-token" + """ + return f"{self.name}-token" + + async def aquire_token_on_behalf_of( + self, scopes: list[str], user_assertion: str + ) -> str: + """ + Acquire a token on behalf of another user (OBO flow). + + Returns a predictable OBO token string for testing scenarios involving + delegated permissions and token exchange. + + Args: (unused in test implementation) + scopes: List of OAuth scopes requested for the OBO token + user_assertion: JWT token representing the user's identity + + Returns: + str: Test OBO token in format "{name}-obo-token" + """ + return f"{self.name}-obo-token" + + +class TestingConnectionManager(Connections): + """ + Connection manager for unit tests. + + This test double provides a simplified connection management interface that + returns TestingTokenProvider instances for all connection requests. It enables + testing of authorization flows without requiring actual OAuth configurations + or external authentication services. + """ + + def get_connection(self, connection_name: str) -> AccessTokenProviderBase: + """ + Get a token provider for the specified connection name. + + Args: + connection_name: Name of the OAuth connection + + Returns: + AccessTokenProviderBase: TestingTokenProvider configured with the connection name + """ + return TestingTokenProvider(connection_name) + + def get_default_connection(self) -> AccessTokenProviderBase: + """ + Get the default token provider. + + Returns: + AccessTokenProviderBase: TestingTokenProvider configured with "default" name + """ + return TestingTokenProvider("default") + + def get_token_provider( + self, claims_identity: ClaimsIdentity, service_url: str + ) -> AccessTokenProviderBase: + """ + Get a token provider based on claims identity and service URL. + + In this test implementation, returns the default connection regardless + of the provided parameters. + + Args: (unused in test implementation) + claims_identity: User's claims and identity information + service_url: URL of the service requiring authentication + + Returns: + AccessTokenProviderBase: The default TestingTokenProvider + """ + return self.get_default_connection() + + def get_default_connection_configuration(self) -> AgentAuthConfiguration: + """ + Get the default authentication configuration. + + Returns: + AgentAuthConfiguration: Empty configuration suitable for testing + """ + return AgentAuthConfiguration() + + +class TestingAuthorization(Authorization): + """ + Authorization system for comprehensive unit testing. + + This test double extends the Authorization class to provide a fully mocked + authorization environment suitable for testing various authentication scenarios. + It automatically configures auth handlers with mock OAuth flows that can simulate + different states like successful authentication, failed sign-in, or in-progress flows. + """ + + def __init__( + self, + auth_handlers: Dict[str, AuthHandler], + token: Union[str, None] = "default", + flow_started=False, + sign_in_failed=False, + ): + """ + Initialize the testing authorization system. + + Sets up a complete test authorization environment with memory storage, + test connection manager, and configures all provided auth handlers with + mock OAuth flows. + + Args: + auth_handlers: Dictionary mapping handler names to AuthHandler instances + token: Token value to use in mock responses. "default" uses auto-generated + tokens, None simulates no token available, or provide custom jwt token string + flow_started: Simulate OAuth flows that have already started + sign_in_failed: Simulate failed sign-in attempts + """ + # Initialize with test-friendly components + storage = MemoryStorage() + connection_manager = TestingConnectionManager() + super().__init__( + storage=storage, + auth_handlers=auth_handlers, + connection_manager=connection_manager, + service_url="a", + ) + + # Configure each auth handler with mock OAuth flow behavior + for auth_handler in self._auth_handlers.values(): + # Create default token response for this auth handler + default_token = TokenResponse( + connection_name=auth_handler.abs_oauth_connection_name, + token=f"{auth_handler.abs_oauth_connection_name}-token", + ) + + # Determine token response based on configuration + if token == "default": + token_response = default_token + elif token: + token_response = TokenResponse( + connection_name=auth_handler.abs_oauth_connection_name, + token=token, + ) + else: + token_response = None + + # Mock the OAuth flow with configurable behavior + auth_handler.flow = Mock( + get_user_token=AsyncMock(return_value=token_response), + _get_flow_state=AsyncMock( + # sign-in failed requires flow to be started + return_value=oauth_flow.FlowState( + flow_started=(flow_started or sign_in_failed) + ) + ), + begin_flow=AsyncMock(return_value=default_token), + # Mock flow continuation with optional failure simulation + continue_flow=AsyncMock( + return_value=None if sign_in_failed else default_token + ), + ) + + auth_handler.flow.flow_state = None diff --git a/libraries/microsoft-agents-hosting-core/tests/tools/testing_flow.py b/libraries/microsoft-agents-hosting-core/tests/core_tools/testing_flow.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/tests/tools/testing_flow.py rename to libraries/microsoft-agents-hosting-core/tests/core_tools/testing_flow.py diff --git a/libraries/microsoft-agents-hosting-core/tests/tools/testing_oauth.py b/libraries/microsoft-agents-hosting-core/tests/core_tools/testing_oauth.py similarity index 95% rename from libraries/microsoft-agents-hosting-core/tests/tools/testing_oauth.py rename to libraries/microsoft-agents-hosting-core/tests/core_tools/testing_oauth.py index 1a3976b8..28c6afa8 100644 --- a/libraries/microsoft-agents-hosting-core/tests/tools/testing_oauth.py +++ b/libraries/microsoft-agents-hosting-core/tests/core_tools/testing_oauth.py @@ -1,180 +1,180 @@ -from datetime import datetime - -from microsoft_agents.hosting.core.storage.storage_test_utils import MockStoreItem -from microsoft_agents.hosting.core.oauth.flow_state import FlowState, FlowStateTag - -MS_APP_ID = "__ms_app_id" -CHANNEL_ID = "__channel_id" -USER_ID = "__user_id" -ABS_OAUTH_CONNECTION_NAME = "__connection_name" -RES_TOKEN = "__res_token" - -DEF_ARGS = { - "ms_app_id": MS_APP_ID, - "channel_id": CHANNEL_ID, - "user_id": USER_ID, - "connection": ABS_OAUTH_CONNECTION_NAME, -} - - -class FLOW_STATES: - - NOT_STARTED_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.NOT_STARTED, - attempts_remaining=1, - user_token="____", - expiration=datetime.now().timestamp() + 1000000, - ) - - STARTED_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.BEGIN, - attempts_remaining=1, - user_token="____", - expiration=datetime.now().timestamp() + 1000000, - ) - STARTED_FLOW_ONE_RETRY = FlowState( - **DEF_ARGS, - tag=FlowStateTag.BEGIN, - attempts_remaining=2, - user_token="____", - expiration=datetime.now().timestamp() + 1000000, - ) - ACTIVE_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.CONTINUE, - attempts_remaining=2, - user_token="__token", - expiration=datetime.now().timestamp() + 1000000, - ) - ACTIVE_FLOW_ONE_RETRY = FlowState( - **DEF_ARGS, - tag=FlowStateTag.CONTINUE, - attempts_remaining=1, - user_token="__token", - expiration=datetime.now().timestamp() + 1000000, - ) - ACTIVE_EXP_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.CONTINUE, - attempts_remaining=2, - user_token="__token", - expiration=datetime.now().timestamp(), - ) - COMPLETED_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.COMPLETE, - attempts_remaining=2, - user_token="test_token", - expiration=datetime.now().timestamp() + 1000000, - ) - FAIL_BY_ATTEMPTS_FLOW = FlowState( - **DEF_ARGS, - tag=FlowStateTag.FAILURE, - attempts_remaining=0, - expiration=datetime.now().timestamp() + 1000000, - ) - - FAIL_BY_EXP_FLOW = FlowState( - **DEF_ARGS, tag=FlowStateTag.FAILURE, attempts_remaining=2, expiration=0 - ) - - @staticmethod - def clone_state_list(lst): - return [flow_state.model_copy() for flow_state in lst] - - @staticmethod - def ALL(): - return FLOW_STATES.clone_state_list( - [ - FLOW_STATES.STARTED_FLOW, - FLOW_STATES.STARTED_FLOW_ONE_RETRY, - FLOW_STATES.ACTIVE_FLOW, - FLOW_STATES.ACTIVE_FLOW_ONE_RETRY, - FLOW_STATES.ACTIVE_EXP_FLOW, - FLOW_STATES.COMPLETED_FLOW, - FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW, - FLOW_STATES.FAIL_BY_EXP_FLOW, - ] - ) - - @staticmethod - def FAILED(): - return FLOW_STATES.clone_state_list( - [ - FLOW_STATES.ACTIVE_EXP_FLOW, - FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW, - FLOW_STATES.FAIL_BY_EXP_FLOW, - ] - ) - - @staticmethod - def ACTIVE(): - return FLOW_STATES.clone_state_list( - [ - FLOW_STATES.STARTED_FLOW, - FLOW_STATES.STARTED_FLOW_ONE_RETRY, - FLOW_STATES.ACTIVE_FLOW, - FLOW_STATES.ACTIVE_FLOW_ONE_RETRY, - ] - ) - - @staticmethod - def INACTIVE(): - return FLOW_STATES.clone_state_list( - [ - FLOW_STATES.ACTIVE_EXP_FLOW, - FLOW_STATES.COMPLETED_FLOW, - FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW, - FLOW_STATES.FAIL_BY_EXP_FLOW, - ] - ) - - -def flow_key(channel_id, user_id, handler_id): - return f"auth/{channel_id}/{user_id}/{handler_id}" - - -def update_flow_state_handler(flow_state, handler): - flow_state = flow_state.model_copy() - flow_state.auth_handler_id = handler - return flow_state - - -STORAGE_SAMPLE_DICT = { - "user_id": MockStoreItem({"id": "123"}), - "session_id": MockStoreItem({"id": "abc"}), - flow_key("webchat", "Alice", "graph"): update_flow_state_handler( - FLOW_STATES.COMPLETED_FLOW.model_copy(), "graph" - ), - flow_key("webchat", "Alice", "github"): update_flow_state_handler( - FLOW_STATES.ACTIVE_FLOW_ONE_RETRY.model_copy(), "github" - ), - flow_key("teams", "Alice", "graph"): update_flow_state_handler( - FLOW_STATES.STARTED_FLOW.model_copy(), "graph" - ), - flow_key("webchat", "Bob", "graph"): update_flow_state_handler( - FLOW_STATES.ACTIVE_EXP_FLOW.model_copy(), "graph" - ), - flow_key("teams", "Bob", "slack"): update_flow_state_handler( - FLOW_STATES.STARTED_FLOW.model_copy(), "slack" - ), - flow_key("webchat", "Chuck", "github"): update_flow_state_handler( - FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW.model_copy(), "github" - ), -} - - -def STORAGE_INIT_DATA(): - data = STORAGE_SAMPLE_DICT.copy() - for key, value in data.items(): - data[key] = value.model_copy() if isinstance(value, FlowState) else value - return data - - -def update_data_with_flow_state(data, channel_id, user_id, auth_handler_id, flow_state): - data = data.copy() - key = f"auth/{channel_id}/{user_id}/{auth_handler_id}" - data[key] = flow_state.model_copy() - return data +from datetime import datetime + +from microsoft_agents.hosting.core.storage._storage_test_utils import MockStoreItem +from microsoft_agents.hosting.core.oauth.flow_state import FlowState, FlowStateTag + +MS_APP_ID = "__ms_app_id" +CHANNEL_ID = "__channel_id" +USER_ID = "__user_id" +ABS_OAUTH_CONNECTION_NAME = "__connection_name" +RES_TOKEN = "__res_token" + +DEF_ARGS = { + "ms_app_id": MS_APP_ID, + "channel_id": CHANNEL_ID, + "user_id": USER_ID, + "connection": ABS_OAUTH_CONNECTION_NAME, +} + + +class FLOW_STATES: + + NOT_STARTED_FLOW = FlowState( + **DEF_ARGS, + tag=FlowStateTag.NOT_STARTED, + attempts_remaining=1, + user_token="____", + expiration=datetime.now().timestamp() + 1000000, + ) + + STARTED_FLOW = FlowState( + **DEF_ARGS, + tag=FlowStateTag.BEGIN, + attempts_remaining=1, + user_token="____", + expiration=datetime.now().timestamp() + 1000000, + ) + STARTED_FLOW_ONE_RETRY = FlowState( + **DEF_ARGS, + tag=FlowStateTag.BEGIN, + attempts_remaining=2, + user_token="____", + expiration=datetime.now().timestamp() + 1000000, + ) + ACTIVE_FLOW = FlowState( + **DEF_ARGS, + tag=FlowStateTag.CONTINUE, + attempts_remaining=2, + user_token="__token", + expiration=datetime.now().timestamp() + 1000000, + ) + ACTIVE_FLOW_ONE_RETRY = FlowState( + **DEF_ARGS, + tag=FlowStateTag.CONTINUE, + attempts_remaining=1, + user_token="__token", + expiration=datetime.now().timestamp() + 1000000, + ) + ACTIVE_EXP_FLOW = FlowState( + **DEF_ARGS, + tag=FlowStateTag.CONTINUE, + attempts_remaining=2, + user_token="__token", + expiration=datetime.now().timestamp(), + ) + COMPLETED_FLOW = FlowState( + **DEF_ARGS, + tag=FlowStateTag.COMPLETE, + attempts_remaining=2, + user_token="test_token", + expiration=datetime.now().timestamp() + 1000000, + ) + FAIL_BY_ATTEMPTS_FLOW = FlowState( + **DEF_ARGS, + tag=FlowStateTag.FAILURE, + attempts_remaining=0, + expiration=datetime.now().timestamp() + 1000000, + ) + + FAIL_BY_EXP_FLOW = FlowState( + **DEF_ARGS, tag=FlowStateTag.FAILURE, attempts_remaining=2, expiration=0 + ) + + @staticmethod + def clone_state_list(lst): + return [flow_state.model_copy() for flow_state in lst] + + @staticmethod + def ALL(): + return FLOW_STATES.clone_state_list( + [ + FLOW_STATES.STARTED_FLOW, + FLOW_STATES.STARTED_FLOW_ONE_RETRY, + FLOW_STATES.ACTIVE_FLOW, + FLOW_STATES.ACTIVE_FLOW_ONE_RETRY, + FLOW_STATES.ACTIVE_EXP_FLOW, + FLOW_STATES.COMPLETED_FLOW, + FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW, + FLOW_STATES.FAIL_BY_EXP_FLOW, + ] + ) + + @staticmethod + def FAILED(): + return FLOW_STATES.clone_state_list( + [ + FLOW_STATES.ACTIVE_EXP_FLOW, + FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW, + FLOW_STATES.FAIL_BY_EXP_FLOW, + ] + ) + + @staticmethod + def ACTIVE(): + return FLOW_STATES.clone_state_list( + [ + FLOW_STATES.STARTED_FLOW, + FLOW_STATES.STARTED_FLOW_ONE_RETRY, + FLOW_STATES.ACTIVE_FLOW, + FLOW_STATES.ACTIVE_FLOW_ONE_RETRY, + ] + ) + + @staticmethod + def INACTIVE(): + return FLOW_STATES.clone_state_list( + [ + FLOW_STATES.ACTIVE_EXP_FLOW, + FLOW_STATES.COMPLETED_FLOW, + FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW, + FLOW_STATES.FAIL_BY_EXP_FLOW, + ] + ) + + +def flow_key(channel_id, user_id, handler_id): + return f"auth/{channel_id}/{user_id}/{handler_id}" + + +def update_flow_state_handler(flow_state, handler): + flow_state = flow_state.model_copy() + flow_state.auth_handler_id = handler + return flow_state + + +STORAGE_SAMPLE_DICT = { + "user_id": MockStoreItem({"id": "123"}), + "session_id": MockStoreItem({"id": "abc"}), + flow_key("webchat", "Alice", "graph"): update_flow_state_handler( + FLOW_STATES.COMPLETED_FLOW.model_copy(), "graph" + ), + flow_key("webchat", "Alice", "github"): update_flow_state_handler( + FLOW_STATES.ACTIVE_FLOW_ONE_RETRY.model_copy(), "github" + ), + flow_key("teams", "Alice", "graph"): update_flow_state_handler( + FLOW_STATES.STARTED_FLOW.model_copy(), "graph" + ), + flow_key("webchat", "Bob", "graph"): update_flow_state_handler( + FLOW_STATES.ACTIVE_EXP_FLOW.model_copy(), "graph" + ), + flow_key("teams", "Bob", "slack"): update_flow_state_handler( + FLOW_STATES.STARTED_FLOW.model_copy(), "slack" + ), + flow_key("webchat", "Chuck", "github"): update_flow_state_handler( + FLOW_STATES.FAIL_BY_ATTEMPTS_FLOW.model_copy(), "github" + ), +} + + +def STORAGE_INIT_DATA(): + data = STORAGE_SAMPLE_DICT.copy() + for key, value in data.items(): + data[key] = value.model_copy() if isinstance(value, FlowState) else value + return data + + +def update_data_with_flow_state(data, channel_id, user_id, auth_handler_id, flow_state): + data = data.copy() + key = f"auth/{channel_id}/{user_id}/{auth_handler_id}" + data[key] = flow_state.model_copy() + return data diff --git a/libraries/microsoft-agents-hosting-core/tests/tools/testing_utility.py b/libraries/microsoft-agents-hosting-core/tests/core_tools/testing_utility.py similarity index 100% rename from libraries/microsoft-agents-hosting-core/tests/tools/testing_utility.py rename to libraries/microsoft-agents-hosting-core/tests/core_tools/testing_utility.py diff --git a/libraries/microsoft-agents-hosting-core/tests/test_agent_state.py b/libraries/microsoft-agents-hosting-core/tests/test_agent_state.py index 668ded70..d88c2f55 100644 --- a/libraries/microsoft-agents-hosting-core/tests/test_agent_state.py +++ b/libraries/microsoft-agents-hosting-core/tests/test_agent_state.py @@ -25,7 +25,7 @@ ChannelAccount, ConversationAccount, ) -from tests.tools.testing_adapter import TestingAdapter +from core_tools.testing_adapter import TestingAdapter class MockCustomState(AgentState): diff --git a/libraries/microsoft-agents-hosting-core/tests/test_authorization.py b/libraries/microsoft-agents-hosting-core/tests/test_authorization.py index bd71952e..f7956ec2 100644 --- a/libraries/microsoft-agents-hosting-core/tests/test_authorization.py +++ b/libraries/microsoft-agents-hosting-core/tests/test_authorization.py @@ -4,7 +4,7 @@ from microsoft_agents.activity import ActivityTypes, TokenResponse from microsoft_agents.hosting.core import MemoryStorage -from microsoft_agents.hosting.core.storage.storage_test_utils import StorageBaseline +from microsoft_agents.hosting.core.storage._storage_test_utils import StorageBaseline from microsoft_agents.hosting.core.connector.user_token_base import UserTokenBase from microsoft_agents.hosting.core.connector.user_token_client_base import ( UserTokenClientBase, @@ -20,8 +20,8 @@ ) # test constants -from .tools.testing_oauth import * -from .tools.testing_authorization import ( +from core_tools.testing_oauth import * +from core_tools.testing_authorization import ( TestingConnectionManager as MockConnectionManager, create_test_auth_handler, ) diff --git a/libraries/microsoft-agents-hosting-core/tests/test_flow_storage_client.py b/libraries/microsoft-agents-hosting-core/tests/test_flow_storage_client.py index cd0dd0ae..b93d7a0d 100644 --- a/libraries/microsoft-agents-hosting-core/tests/test_flow_storage_client.py +++ b/libraries/microsoft-agents-hosting-core/tests/test_flow_storage_client.py @@ -1,7 +1,7 @@ import pytest from microsoft_agents.hosting.core.storage import MemoryStorage -from microsoft_agents.hosting.core.storage.storage_test_utils import MockStoreItem +from microsoft_agents.hosting.core.storage._storage_test_utils import MockStoreItem from microsoft_agents.hosting.core.oauth import FlowState, FlowStorageClient diff --git a/libraries/microsoft-agents-hosting-core/tests/test_memory_storage.py b/libraries/microsoft-agents-hosting-core/tests/test_memory_storage.py index 1ecaef4a..6e6cbfcd 100644 --- a/libraries/microsoft-agents-hosting-core/tests/test_memory_storage.py +++ b/libraries/microsoft-agents-hosting-core/tests/test_memory_storage.py @@ -1,5 +1,5 @@ from microsoft_agents.hosting.core.storage.memory_storage import MemoryStorage -from microsoft_agents.hosting.core.storage.storage_test_utils import CRUDStorageTests +from microsoft_agents.hosting.core.storage._storage_test_utils import CRUDStorageTests class TestMemoryStorage(CRUDStorageTests): diff --git a/libraries/microsoft-agents-hosting-core/tests/test_oauth_flow.py b/libraries/microsoft-agents-hosting-core/tests/test_oauth_flow.py index d01d69eb..9a358b00 100644 --- a/libraries/microsoft-agents-hosting-core/tests/test_oauth_flow.py +++ b/libraries/microsoft-agents-hosting-core/tests/test_oauth_flow.py @@ -21,7 +21,7 @@ ) # test constants -from .tools.testing_oauth import * +from core_tools.testing_oauth import * class TestOAuthFlowUtils: diff --git a/libraries/microsoft-agents-hosting-core/tests/test_utils.py b/libraries/microsoft-agents-hosting-core/tests/test_utils.py index f392773d..98a408fc 100644 --- a/libraries/microsoft-agents-hosting-core/tests/test_utils.py +++ b/libraries/microsoft-agents-hosting-core/tests/test_utils.py @@ -1,4 +1,4 @@ -from microsoft_agents.hosting.core.storage.storage_test_utils import ( +from microsoft_agents.hosting.core.storage._storage_test_utils import ( MockStoreItem, MockStoreItemB, my_deepcopy, diff --git a/libraries/microsoft-agents-hosting-core/tests/tools/__init__.py b/libraries/microsoft-agents-hosting-core/tests/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/microsoft-agents-storage-blob/tests/test_blob_storage.py b/libraries/microsoft-agents-storage-blob/tests/test_blob_storage.py index 5b6ce393..b3fde01c 100644 --- a/libraries/microsoft-agents-storage-blob/tests/test_blob_storage.py +++ b/libraries/microsoft-agents-storage-blob/tests/test_blob_storage.py @@ -10,7 +10,7 @@ from azure.storage.blob.aio import BlobServiceClient from azure.core.exceptions import ResourceNotFoundError -from microsoft_agents.hosting.core.storage.storage_test_utils import ( +from microsoft_agents.hosting.core.storage._storage_test_utils import ( CRUDStorageTests, StorageBaseline, MockStoreItem, diff --git a/libraries/microsoft-agents-storage-cosmos/tests/test_cosmos_db_storage.py b/libraries/microsoft-agents-storage-cosmos/tests/test_cosmos_db_storage.py index 3890f852..73c5644b 100644 --- a/libraries/microsoft-agents-storage-cosmos/tests/test_cosmos_db_storage.py +++ b/libraries/microsoft-agents-storage-cosmos/tests/test_cosmos_db_storage.py @@ -13,7 +13,7 @@ from microsoft_agents.storage.cosmos import CosmosDBStorage, CosmosDBStorageConfig from microsoft_agents.storage.cosmos.key_ops import sanitize_key -from microsoft_agents.hosting.core.storage.storage_test_utils import ( +from microsoft_agents.hosting.core.storage._storage_test_utils import ( QuickCRUDStorageTests, MockStoreItem, MockStoreItemB,