Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/api/src/microsoft_teams/api/clients/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@

from typing import Optional

from microsoft_teams.common.experimental import experimental
from microsoft_teams.common.http import Client

from ...models.message import MessageReactionType
from ..api_client_settings import ApiClientSettings
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__(
Expand Down
19 changes: 18 additions & 1 deletion packages/api/src/microsoft_teams/api/models/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Licensed under the MIT License.
"""

import warnings
from datetime import datetime
from typing import Any, List, Optional, Self

Expand All @@ -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

Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion packages/common/src/microsoft_teams/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
79 changes: 79 additions & 0 deletions packages/common/src/microsoft_teams/common/experimental.py
Original file line number Diff line number Diff line change
@@ -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
88 changes: 88 additions & 0 deletions packages/common/tests/test_experimental.py
Original file line number Diff line number Diff line change
@@ -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)