diff --git a/packages/api/src/microsoft_teams/api/clients/api_client.py b/packages/api/src/microsoft_teams/api/clients/api_client.py index 08971af9..fe08502e 100644 --- a/packages/api/src/microsoft_teams/api/clients/api_client.py +++ b/packages/api/src/microsoft_teams/api/clients/api_client.py @@ -43,7 +43,14 @@ def __init__( self.conversations = ConversationClient(service_url, self._http, self._api_client_settings) self.teams = TeamClient(service_url, self._http, self._api_client_settings) self.meetings = MeetingClient(service_url, self._http, self._api_client_settings) - self.reactions = ReactionClient(service_url, self._http, self._api_client_settings) + self._reactions: Optional[ReactionClient] = None + + @property + def reactions(self) -> ReactionClient: + """Get the reactions client (preview). Lazily instantiated to avoid warnings for non-users.""" + if self._reactions is None: + self._reactions = ReactionClient(self.service_url, self._http, self._api_client_settings) + return self._reactions @property def http(self) -> HttpClient: @@ -58,5 +65,6 @@ def http(self, value: HttpClient) -> None: self.users.http = value self.teams.http = value self.meetings.http = value - self.reactions.http = value + if self._reactions is not None: + self._reactions.http = value self._http = value diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py index e68fb3b3..e4a7c5db 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py @@ -5,6 +5,7 @@ from typing import List, Optional +from microsoft_teams.common.experimental import experimental from microsoft_teams.common.http import Client from ...activities import ActivityParams, SentActivity @@ -126,12 +127,17 @@ async def get_members(self, conversation_id: str, activity_id: str) -> List[Acco ) return [Account.model_validate(member) for member in response.json()] + @experimental("TEAMS0002") async def create_targeted(self, conversation_id: str, activity: ActivityParams) -> SentActivity: """ Create a new targeted activity in a conversation. Targeted activities are only visible to the specified recipient. + .. warning:: Preview + This API is in preview and may change in the future. + Diagnostic: TEAMS0002 + Args: conversation_id: The ID of the conversation activity: The activity to create @@ -146,10 +152,15 @@ async def create_targeted(self, conversation_id: str, activity: ActivityParams) id = response.json().get("id", _PLACEHOLDER_ACTIVITY_ID) return SentActivity(id=id, activity_params=activity) + @experimental("TEAMS0002") async def update_targeted(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity: """ Update an existing targeted activity in a conversation. + .. warning:: Preview + This API is in preview and may change in the future. + Diagnostic: TEAMS0002 + Args: conversation_id: The ID of the conversation activity_id: The ID of the activity to update @@ -165,10 +176,15 @@ async def update_targeted(self, conversation_id: str, activity_id: str, activity id = response.json()["id"] return SentActivity(id=id, activity_params=activity) + @experimental("TEAMS0002") async def delete_targeted(self, conversation_id: str, activity_id: str) -> None: """ Delete a targeted activity from a conversation. + .. warning:: Preview + This API is in preview and may change in the future. + Diagnostic: TEAMS0002 + Args: conversation_id: The ID of the conversation activity_id: The ID of the activity to delete diff --git a/packages/api/src/microsoft_teams/api/clients/reaction/client.py b/packages/api/src/microsoft_teams/api/clients/reaction/client.py index dea23613..b417dec8 100644 --- a/packages/api/src/microsoft_teams/api/clients/reaction/client.py +++ b/packages/api/src/microsoft_teams/api/clients/reaction/client.py @@ -5,6 +5,7 @@ from typing import Optional +from microsoft_teams.common.experimental import experimental from microsoft_teams.common.http import Client from ...models.message import MessageReactionType @@ -12,9 +13,14 @@ from ..base_client import BaseClient +@experimental("TEAMS0001") class ReactionClient(BaseClient): """ Client for working with app message reactions for a given conversation/activity. + + .. warning:: Preview + This API is in preview and may change in the future. + Diagnostic: TEAMS0001 """ def __init__( diff --git a/packages/api/src/microsoft_teams/api/models/activity.py b/packages/api/src/microsoft_teams/api/models/activity.py index 17f7baac..013bc95e 100644 --- a/packages/api/src/microsoft_teams/api/models/activity.py +++ b/packages/api/src/microsoft_teams/api/models/activity.py @@ -3,6 +3,7 @@ Licensed under the MIT License. """ +import warnings from datetime import datetime from typing import Any, List, Optional, Self @@ -24,6 +25,7 @@ from microsoft_teams.api.models.entity.entity import Entity from microsoft_teams.api.models.entity.message_entity import MessageEntity from microsoft_teams.api.models.meetings.meeting_info import MeetingInfo +from microsoft_teams.common.experimental import ExperimentalWarning from .custom_base_model import CustomBaseModel @@ -95,7 +97,12 @@ class ActivityInput(_ActivityBase): """Identifies the recipient of the message.""" is_targeted: Optional[bool] = None - """Indicates if this is a targeted message visible only to a specific recipient.""" + """Indicates if this is a targeted message visible only to a specific recipient. + + .. warning:: Preview + This field is in preview and may change in the future. + Diagnostic: TEAMS0002 + """ @property def channel(self) -> Optional[ChannelInfo]: @@ -162,11 +169,21 @@ def with_recipient(self, value: Account, is_targeted: Optional[bool] = None) -> recipient. If False, explicitly clears targeting. If None (the default), the existing is_targeted value is left unchanged. + .. warning:: Preview + The ``is_targeted`` parameter is in preview and may change or be + removed in future versions. Diagnostic: TEAMS0002 + Returns: Self for method chaining """ self.recipient = value if is_targeted is not None: + warnings.warn( + "The is_targeted parameter of with_recipient is in preview and may change " + "or be removed in future versions. Diagnostic: TEAMS0002", + ExperimentalWarning, + stacklevel=2, + ) self.is_targeted = is_targeted return self diff --git a/packages/common/src/microsoft_teams/common/__init__.py b/packages/common/src/microsoft_teams/common/__init__.py index 17d17ab9..f08c2d25 100644 --- a/packages/common/src/microsoft_teams/common/__init__.py +++ b/packages/common/src/microsoft_teams/common/__init__.py @@ -5,12 +5,13 @@ from . import events, http, logging, storage # noqa: E402 from .events import * # noqa: F401, F402, F403 +from .experimental import ExperimentalWarning, experimental from .http import * # noqa: F401, F402, F403 from .logging import * # noqa: F401, F402, F403 from .storage import * # noqa: F401, F402, F403 # Combine all exports from submodules -__all__: list[str] = [] +__all__: list[str] = ["ExperimentalWarning", "experimental"] __all__.extend(events.__all__) __all__.extend(http.__all__) __all__.extend(logging.__all__) diff --git a/packages/common/src/microsoft_teams/common/experimental.py b/packages/common/src/microsoft_teams/common/experimental.py new file mode 100644 index 00000000..80bbf3af --- /dev/null +++ b/packages/common/src/microsoft_teams/common/experimental.py @@ -0,0 +1,79 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import functools +import inspect +import warnings +from typing import Any, Callable, Optional, TypeVar + +F = TypeVar("F", bound=Callable[..., Any]) + + +class ExperimentalWarning(FutureWarning): + """Warning category for Teams SDK preview APIs. + + Preview APIs may change in the future. + """ + + pass + + +def experimental(diagnostic: str, *, message: Optional[str] = None) -> Callable[[F], F]: + """Mark a class or function as a preview API. + + Emits an ExperimentalWarning when the decorated class is instantiated + or the decorated function is called. + + Args: + diagnostic: The diagnostic code (e.g., "TEAMS0001") for granular opt-in. + message: Optional custom warning message. If not provided, a default message is used. + + Usage:: + + @experimental("TEAMS0001") + class ReactionClient: + ... + + @experimental("TEAMS0002", message="Targeted messages are in preview.") + async def create_targeted(...): + ... + """ + + def decorator(obj: F) -> F: + name = getattr(obj, "__qualname__", getattr(obj, "__name__", str(obj))) + warn_msg = message or ( + f"{name} is in preview and may change in the future. " + f"Diagnostic: {diagnostic}" + ) + + if isinstance(obj, type): + original_init = obj.__init__ + + @functools.wraps(original_init) + def new_init(self: Any, *args: Any, **kwargs: Any) -> None: + warnings.warn(warn_msg, ExperimentalWarning, stacklevel=2) + original_init(self, *args, **kwargs) + + obj.__init__ = new_init # type: ignore[misc] + return obj # type: ignore[return-value] + else: + if inspect.iscoroutinefunction(obj): + + @functools.wraps(obj) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + warnings.warn(warn_msg, ExperimentalWarning, stacklevel=2) + return await obj(*args, **kwargs) + + return async_wrapper # type: ignore[return-value] + else: + + @functools.wraps(obj) + def wrapper(*args: Any, **kwargs: Any) -> Any: + warnings.warn(warn_msg, ExperimentalWarning, stacklevel=2) + return obj(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + return decorator diff --git a/packages/common/tests/test_experimental.py b/packages/common/tests/test_experimental.py new file mode 100644 index 00000000..442b3552 --- /dev/null +++ b/packages/common/tests/test_experimental.py @@ -0,0 +1,88 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import asyncio +import warnings + +from microsoft_teams.common.experimental import ExperimentalWarning, experimental + + +@experimental("TEST001") +class _PreviewClass: + def __init__(self, value: str): + self.value = value + + +@experimental("TEST002") +def _preview_sync_func(x: int) -> int: + return x * 2 + + +@experimental("TEST003") +async def _preview_async_func(x: int) -> int: + return x * 3 + + +class TestExperimentalWarning: + def test_class_instantiation_emits_warning(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + obj = _PreviewClass("test") + assert len(w) == 1 + assert issubclass(w[0].category, ExperimentalWarning) + assert "TEST001" in str(w[0].message) + assert "preview" in str(w[0].message).lower() + assert obj.value == "test" + + def test_sync_function_emits_warning(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = _preview_sync_func(5) + assert len(w) == 1 + assert issubclass(w[0].category, ExperimentalWarning) + assert "TEST002" in str(w[0].message) + assert result == 10 + + def test_async_function_emits_warning(self): + async def _run() -> int: + return await _preview_async_func(5) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = asyncio.run(_run()) + assert len(w) == 1 + assert issubclass(w[0].category, ExperimentalWarning) + assert "TEST003" in str(w[0].message) + assert result == 15 + + def test_warning_is_suppressible(self): + with warnings.catch_warnings(record=True) as w: + warnings.filterwarnings("ignore", category=ExperimentalWarning) + _PreviewClass("suppressed") + assert len(w) == 0 + + def test_warning_is_suppressible_by_message(self): + with warnings.catch_warnings(record=True) as w: + warnings.filterwarnings("ignore", message=".*TEST001.*", category=ExperimentalWarning) + _PreviewClass("suppressed") + result = _preview_sync_func(5) + assert len(w) == 1 # only TEST002 warning, not TEST001 + assert "TEST002" in str(w[0].message) + assert result == 10 + + def test_custom_message(self): + @experimental("CUSTOM", message="Custom preview message.") + def custom_func() -> str: + return "ok" + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = custom_func() + assert len(w) == 1 + assert str(w[0].message) == "Custom preview message." + assert result == "ok" + + def test_warning_is_future_warning_subclass(self): + assert issubclass(ExperimentalWarning, FutureWarning)