mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add Python feature lifecycle decorators for released APIs (#4975)
* Add Python feature lifecycle decorators Introduce reusable experimental and release-candidate decorators for released packages, migrate the Skills APIs to the new staged metadata and warning system, and add lifecycle guidance plus samples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Python CI follow-ups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Preserve protocol runtime checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
9c9d81d8b6
commit
55b6e7a9f4
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import warnings
|
||||
from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence
|
||||
from typing import Any, Generic
|
||||
from unittest.mock import patch
|
||||
@@ -10,7 +11,13 @@ from uuid import uuid4
|
||||
|
||||
from pytest import fixture
|
||||
|
||||
from agent_framework import (
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=r"\[SKILLS\].*",
|
||||
category=FutureWarning,
|
||||
)
|
||||
|
||||
from agent_framework import ( # noqa: E402
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
@@ -26,8 +33,8 @@ from agent_framework import (
|
||||
SupportsAgentRun,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._clients import OptionsCoT
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from agent_framework._clients import OptionsCoT # noqa: E402
|
||||
from agent_framework.observability import ChatTelemetryLayer # noqa: E402
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework._docstrings import apply_layered_docstring, build_layered_docstring
|
||||
from agent_framework._docstrings import apply_layered_docstring, build_layered_docstring, insert_docstring_block
|
||||
|
||||
# -- Helpers: stub functions with various docstring shapes --
|
||||
|
||||
@@ -36,6 +36,14 @@ def _source_no_sections() -> None:
|
||||
"""A plain summary with no Google-style sections."""
|
||||
|
||||
|
||||
def _source_with_attributes() -> None:
|
||||
"""A documented object.
|
||||
|
||||
Attributes:
|
||||
value: A documented attribute.
|
||||
"""
|
||||
|
||||
|
||||
def _source_no_docstring() -> None:
|
||||
pass
|
||||
|
||||
@@ -141,6 +149,67 @@ def test_build_preserves_multiple_extra_kwargs_order() -> None:
|
||||
assert alpha_idx < beta_idx < gamma_idx
|
||||
|
||||
|
||||
# -- insert_docstring_block tests --
|
||||
|
||||
|
||||
def test_insert_docstring_block_before_args_section() -> None:
|
||||
result = insert_docstring_block(
|
||||
_source_with_args_only.__doc__,
|
||||
block="""\
|
||||
.. warning:: Experimental
|
||||
|
||||
This API is experimental.
|
||||
""",
|
||||
)
|
||||
assert result is not None
|
||||
lines = result.splitlines()
|
||||
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||
args_index = next(i for i, line in enumerate(lines) if line == "Args:")
|
||||
assert warning_index < args_index
|
||||
|
||||
|
||||
def test_insert_docstring_block_before_attributes_section() -> None:
|
||||
result = insert_docstring_block(
|
||||
_source_with_attributes.__doc__,
|
||||
block="""\
|
||||
.. warning:: Experimental
|
||||
|
||||
This API is experimental.
|
||||
""",
|
||||
)
|
||||
assert result is not None
|
||||
lines = result.splitlines()
|
||||
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||
attributes_index = next(i for i, line in enumerate(lines) if line == "Attributes:")
|
||||
assert warning_index < attributes_index
|
||||
|
||||
|
||||
def test_insert_docstring_block_appends_when_no_sections() -> None:
|
||||
result = insert_docstring_block(
|
||||
_source_no_sections.__doc__,
|
||||
block="""\
|
||||
.. note:: Release candidate
|
||||
|
||||
This API is nearly final.
|
||||
""",
|
||||
)
|
||||
assert result is not None
|
||||
assert result.endswith("This API is nearly final.")
|
||||
assert ".. note:: Release candidate" in result
|
||||
|
||||
|
||||
def test_insert_docstring_block_returns_block_for_missing_docstring() -> None:
|
||||
result = insert_docstring_block(
|
||||
_source_no_docstring.__doc__,
|
||||
block="""\
|
||||
.. warning:: Experimental
|
||||
|
||||
This API is experimental.
|
||||
""",
|
||||
)
|
||||
assert result == ".. warning:: Experimental\n\n This API is experimental."
|
||||
|
||||
|
||||
# -- apply_layered_docstring tests --
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import ExperimentalFeature as PublicExperimentalFeature
|
||||
from agent_framework import ReleaseCandidateFeature as PublicReleaseCandidateFeature
|
||||
from agent_framework._feature_stage import (
|
||||
_WARNED_FEATURES,
|
||||
ExperimentalWarning,
|
||||
_feature_stage,
|
||||
experimental,
|
||||
release_candidate,
|
||||
)
|
||||
from agent_framework._feature_stage import (
|
||||
ExperimentalFeature as InternalExperimentalFeature,
|
||||
)
|
||||
from agent_framework._feature_stage import (
|
||||
ReleaseCandidateFeature as InternalReleaseCandidateFeature,
|
||||
)
|
||||
|
||||
|
||||
class AlternateExperimentalFeature(str, Enum):
|
||||
EXPERIMENTAL_FEATURE = "EXPERIMENTAL_FEATURE"
|
||||
SHARED_FEATURE = "SHARED_EXPERIMENTAL_FEATURE"
|
||||
ALTERNATE_FEATURE = "ALTERNATE_EXPERIMENTAL_FEATURE"
|
||||
|
||||
|
||||
class InvalidStageFeature(str, Enum):
|
||||
LOWERCASE = "skills"
|
||||
|
||||
|
||||
class NonStringFeature(Enum):
|
||||
INTEGER = 1
|
||||
|
||||
|
||||
class HelperReleaseCandidateFeature(str, Enum):
|
||||
RC_FEATURE = "RC_FEATURE"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_feature_warning_state() -> None:
|
||||
_WARNED_FEATURES.clear()
|
||||
yield
|
||||
_WARNED_FEATURES.clear()
|
||||
|
||||
|
||||
def test_feature_enums_are_exposed_from_root() -> None:
|
||||
assert PublicExperimentalFeature is InternalExperimentalFeature
|
||||
assert PublicReleaseCandidateFeature is InternalReleaseCandidateFeature
|
||||
|
||||
|
||||
def test_experimental_decorator_accepts_feature_enum() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||
def skill_function() -> None:
|
||||
pass
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
skill_function()
|
||||
|
||||
assert len(caught) == 1
|
||||
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||
assert "skill_function" in str(caught[0].message)
|
||||
assert skill_function.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||
|
||||
|
||||
def test_experimental_function_warns_on_call_and_not_on_definition() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||
def my_function(value: int) -> int:
|
||||
"""Double the input.
|
||||
|
||||
Args:
|
||||
value: Value to double.
|
||||
|
||||
Returns:
|
||||
The doubled value.
|
||||
"""
|
||||
return value * 2
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
assert my_function(3) == 6
|
||||
assert my_function(4) == 8
|
||||
|
||||
assert len(caught) == 1
|
||||
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||
assert "my_function" in str(caught[0].message)
|
||||
assert my_function.__feature_stage__ == "experimental"
|
||||
assert my_function.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||
assert my_function.__doc__ is not None
|
||||
lines = my_function.__doc__.splitlines()
|
||||
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||
args_index = next(i for i, line in enumerate(lines) if line == "Args:")
|
||||
assert warning_index < args_index
|
||||
|
||||
|
||||
def test_experimental_class_warns_on_instantiation_and_not_on_definition() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||
class ExperimentalClass:
|
||||
"""An experimental class.
|
||||
|
||||
Args:
|
||||
value: Value to store.
|
||||
"""
|
||||
|
||||
def __init__(self, value: int) -> None:
|
||||
self.value = value
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
instantiation_line = inspect.currentframe().f_lineno + 1
|
||||
instance = ExperimentalClass(4)
|
||||
second_instance = ExperimentalClass(5)
|
||||
|
||||
assert len(caught) == 1
|
||||
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||
assert "ExperimentalClass" in str(caught[0].message)
|
||||
assert caught[0].filename == __file__
|
||||
assert caught[0].lineno == instantiation_line
|
||||
assert instance.value == 4
|
||||
assert second_instance.value == 5
|
||||
assert ExperimentalClass.__feature_stage__ == "experimental"
|
||||
assert ExperimentalClass.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||
|
||||
|
||||
def test_experimental_runtime_checkable_protocol_keeps_protocol_runtime_checks() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@runtime_checkable
|
||||
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||
class ExampleProtocol(Protocol):
|
||||
"""A protocol used for runtime checks.
|
||||
|
||||
Returns:
|
||||
Nothing.
|
||||
"""
|
||||
|
||||
def __call__(self, value: int) -> int: ...
|
||||
|
||||
assert not caught
|
||||
|
||||
def implementation(value: int) -> int:
|
||||
return value
|
||||
|
||||
assert isinstance(implementation, ExampleProtocol)
|
||||
assert ExampleProtocol.__doc__ is not None
|
||||
assert ".. warning:: Experimental" in ExampleProtocol.__doc__
|
||||
assert getattr(ExampleProtocol, "__feature_stage__", None) is None
|
||||
assert getattr(ExampleProtocol, "__feature_id__", None) is None
|
||||
|
||||
|
||||
def test_experimental_warning_is_emitted_once_per_feature() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@experimental(feature_id=AlternateExperimentalFeature.SHARED_FEATURE) # type: ignore[arg-type]
|
||||
def first() -> None:
|
||||
pass
|
||||
|
||||
@experimental(feature_id=AlternateExperimentalFeature.SHARED_FEATURE) # type: ignore[arg-type]
|
||||
class Second:
|
||||
pass
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
first()
|
||||
Second()
|
||||
|
||||
assert first is not None
|
||||
assert Second is not None
|
||||
assert len(caught) == 1
|
||||
assert f"[{AlternateExperimentalFeature.SHARED_FEATURE.value}]" in str(caught[0].message)
|
||||
assert "first" in str(caught[0].message)
|
||||
|
||||
|
||||
def test_release_candidate_internal_helper_adds_metadata_without_runtime_warning() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@_feature_stage(
|
||||
stage="release_candidate",
|
||||
feature_id=HelperReleaseCandidateFeature.RC_FEATURE,
|
||||
docstring_block="""\
|
||||
.. note:: Release candidate
|
||||
|
||||
This API is in release-candidate stage and may receive
|
||||
minor refinements before it is considered generally available.
|
||||
""",
|
||||
warning_category=None,
|
||||
)
|
||||
class ReleaseCandidateClass:
|
||||
"""A release-candidate class.
|
||||
|
||||
Args:
|
||||
value: Value to store.
|
||||
"""
|
||||
|
||||
def __init__(self, value: int) -> None:
|
||||
self.value = value
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
instance = ReleaseCandidateClass(5)
|
||||
|
||||
assert instance.value == 5
|
||||
assert not caught
|
||||
assert ReleaseCandidateClass.__feature_stage__ == "release_candidate"
|
||||
assert ReleaseCandidateClass.__feature_id__ == HelperReleaseCandidateFeature.RC_FEATURE.value
|
||||
assert ReleaseCandidateClass.__doc__ is not None
|
||||
assert ".. note:: Release candidate" in ReleaseCandidateClass.__doc__
|
||||
|
||||
|
||||
def test_experimental_property_warns_on_access_and_not_on_definition() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
class Example:
|
||||
@property
|
||||
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||
def value(self) -> int:
|
||||
"""Return the value.
|
||||
|
||||
Returns:
|
||||
The stored value.
|
||||
"""
|
||||
return 1
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
assert Example().value == 1
|
||||
assert Example().value == 1
|
||||
|
||||
assert len(caught) == 1
|
||||
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||
assert "Example.value" in str(caught[0].message)
|
||||
assert Example.value.__doc__ is not None
|
||||
lines = Example.value.__doc__.splitlines()
|
||||
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||
returns_index = next(i for i, line in enumerate(lines) if line == "Returns:")
|
||||
assert warning_index < returns_index
|
||||
|
||||
|
||||
def test_experimental_staticmethod_warns_when_decorator_wraps_descriptor() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
class Example:
|
||||
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||
@staticmethod
|
||||
def value() -> int:
|
||||
"""Return the value.
|
||||
|
||||
Returns:
|
||||
The stored value.
|
||||
"""
|
||||
return 1
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
assert Example.value() == 1
|
||||
assert Example.value() == 1
|
||||
|
||||
assert len(caught) == 1
|
||||
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||
assert "Example.value" in str(caught[0].message)
|
||||
assert Example.value.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||
assert Example.value.__doc__ is not None
|
||||
lines = Example.value.__doc__.splitlines()
|
||||
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||
returns_index = next(i for i, line in enumerate(lines) if line == "Returns:")
|
||||
assert warning_index < returns_index
|
||||
|
||||
|
||||
def test_experimental_classmethod_warns_when_decorator_wraps_descriptor() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
class Example:
|
||||
@experimental(feature_id=AlternateExperimentalFeature.EXPERIMENTAL_FEATURE) # type: ignore[arg-type]
|
||||
@classmethod
|
||||
def value(cls) -> int:
|
||||
"""Return the value.
|
||||
|
||||
Returns:
|
||||
The stored value.
|
||||
"""
|
||||
return 1
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
assert Example.value() == 1
|
||||
assert Example.value() == 1
|
||||
|
||||
assert len(caught) == 1
|
||||
assert f"[{AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value}]" in str(caught[0].message)
|
||||
assert "Example.value" in str(caught[0].message)
|
||||
assert Example.value.__func__.__feature_id__ == AlternateExperimentalFeature.EXPERIMENTAL_FEATURE.value
|
||||
assert Example.value.__doc__ is not None
|
||||
lines = Example.value.__doc__.splitlines()
|
||||
warning_index = next(i for i, line in enumerate(lines) if line == ".. warning:: Experimental")
|
||||
returns_index = next(i for i, line in enumerate(lines) if line == "Returns:")
|
||||
assert warning_index < returns_index
|
||||
|
||||
|
||||
def test_feature_id_allows_lowercase_values() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@_feature_stage(
|
||||
stage="experimental",
|
||||
feature_id=InvalidStageFeature.LOWERCASE,
|
||||
docstring_block=".. warning:: Experimental",
|
||||
warning_category=ExperimentalWarning,
|
||||
)
|
||||
def lowercase_feature() -> None:
|
||||
pass
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
lowercase_feature()
|
||||
|
||||
assert len(caught) == 1
|
||||
assert "[skills]" in str(caught[0].message)
|
||||
assert "lowercase_feature" in str(caught[0].message)
|
||||
assert lowercase_feature.__feature_id__ == "skills"
|
||||
|
||||
|
||||
def test_experimental_decorator_allows_string_feature_id_at_runtime() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@experimental(feature_id="STRING_FEATURE") # type: ignore[arg-type]
|
||||
def skill_function() -> None:
|
||||
pass
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
skill_function()
|
||||
|
||||
assert len(caught) == 1
|
||||
assert "[STRING_FEATURE]" in str(caught[0].message)
|
||||
assert "skill_function" in str(caught[0].message)
|
||||
assert skill_function.__feature_id__ == "STRING_FEATURE"
|
||||
|
||||
|
||||
def test_experimental_decorator_allows_other_enum_values_at_runtime() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@experimental(feature_id=AlternateExperimentalFeature.ALTERNATE_FEATURE) # type: ignore[arg-type]
|
||||
def my_function() -> None:
|
||||
pass
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
my_function()
|
||||
|
||||
assert len(caught) == 1
|
||||
assert f"[{AlternateExperimentalFeature.ALTERNATE_FEATURE.value}]" in str(caught[0].message)
|
||||
assert "my_function" in str(caught[0].message)
|
||||
assert my_function.__feature_id__ == AlternateExperimentalFeature.ALTERNATE_FEATURE.value
|
||||
|
||||
|
||||
def test_release_candidate_decorator_allows_string_feature_id_at_runtime() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@release_candidate(feature_id="RC_FEATURE") # type: ignore[arg-type]
|
||||
class ReleaseCandidateClass:
|
||||
"""A release-candidate class."""
|
||||
|
||||
assert not caught
|
||||
assert ReleaseCandidateClass.__feature_stage__ == "release_candidate"
|
||||
assert ReleaseCandidateClass.__feature_id__ == "RC_FEATURE"
|
||||
|
||||
|
||||
def test_feature_id_stringifies_non_string_enum_values() -> None:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
|
||||
@_feature_stage(
|
||||
stage="experimental",
|
||||
feature_id=NonStringFeature.INTEGER,
|
||||
docstring_block=".. warning:: Experimental",
|
||||
warning_category=ExperimentalWarning,
|
||||
)
|
||||
def numeric_feature() -> None:
|
||||
pass
|
||||
|
||||
assert not caught
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
numeric_feature()
|
||||
|
||||
assert len(caught) == 1
|
||||
assert "[1]" in str(caught[0].message)
|
||||
assert "numeric_feature" in str(caught[0].message)
|
||||
assert numeric_feature.__feature_id__ == "1"
|
||||
@@ -11,7 +11,7 @@ from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import SessionContext, Skill, SkillResource, SkillsProvider
|
||||
from agent_framework import SessionContext, Skill, SkillResource, SkillScript, SkillScriptRunner, SkillsProvider
|
||||
from agent_framework._skills import (
|
||||
DEFAULT_RESOURCE_EXTENSIONS,
|
||||
DEFAULT_SCRIPT_EXTENSIONS,
|
||||
@@ -32,6 +32,8 @@ from agent_framework._skills import (
|
||||
_validate_skill_metadata,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.filterwarnings(r"ignore:\[SKILLS\].*:FutureWarning")
|
||||
|
||||
|
||||
async def _noop_script_runner(skill: Any, script: Any, args: Any = None) -> None:
|
||||
"""No-op script runner for tests that need a SkillScriptRunner."""
|
||||
@@ -778,6 +780,44 @@ class TestSymlinkDetection:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillsExperimentalStage:
|
||||
"""Tests for the experimental stage annotations applied to skills APIs."""
|
||||
|
||||
def test_docstrings_include_experimental_warning(self) -> None:
|
||||
assert SkillResource.__doc__ is not None
|
||||
assert SkillScript.__doc__ is not None
|
||||
assert Skill.__doc__ is not None
|
||||
assert SkillScriptRunner.__doc__ is not None
|
||||
assert SkillsProvider.__doc__ is not None
|
||||
assert SkillScript.parameters_schema.__doc__ is not None
|
||||
|
||||
assert ".. warning:: Experimental" in SkillResource.__doc__
|
||||
assert ".. warning:: Experimental" in SkillScript.__doc__
|
||||
assert ".. warning:: Experimental" in Skill.__doc__
|
||||
assert ".. warning:: Experimental" in SkillScriptRunner.__doc__
|
||||
assert ".. warning:: Experimental" in SkillsProvider.__doc__
|
||||
assert ".. warning:: Experimental" not in SkillScript.parameters_schema.__doc__
|
||||
|
||||
def test_feature_metadata_is_set(self) -> None:
|
||||
assert SkillResource.__feature_stage__ == "experimental"
|
||||
assert SkillScript.__feature_stage__ == "experimental"
|
||||
assert Skill.__feature_stage__ == "experimental"
|
||||
assert SkillsProvider.__feature_stage__ == "experimental"
|
||||
feature_ids = [
|
||||
SkillResource.__feature_id__,
|
||||
SkillScript.__feature_id__,
|
||||
Skill.__feature_id__,
|
||||
SkillsProvider.__feature_id__,
|
||||
]
|
||||
assert all(isinstance(feature_id, str) and feature_id for feature_id in feature_ids)
|
||||
assert len(set(feature_ids)) == 1
|
||||
assert getattr(SkillScriptRunner, "__feature_stage__", None) is None
|
||||
assert getattr(SkillScriptRunner, "__feature_id__", None) is None
|
||||
assert SkillScript.parameters_schema.fget is not None
|
||||
assert not hasattr(SkillScript.parameters_schema.fget, "__feature_stage__")
|
||||
assert not hasattr(SkillScript.parameters_schema.fget, "__feature_id__")
|
||||
|
||||
|
||||
class TestSkillResource:
|
||||
"""Tests for SkillResource dataclass."""
|
||||
|
||||
@@ -1839,40 +1879,28 @@ class TestSkillScript:
|
||||
"""Tests for the SkillScript data model."""
|
||||
|
||||
def test_empty_name_raises(self) -> None:
|
||||
from agent_framework import SkillScript
|
||||
|
||||
with pytest.raises(ValueError, match="Script name cannot be empty"):
|
||||
SkillScript(name="")
|
||||
|
||||
def test_whitespace_name_raises(self) -> None:
|
||||
from agent_framework import SkillScript
|
||||
|
||||
with pytest.raises(ValueError, match="Script name cannot be empty"):
|
||||
SkillScript(name=" ")
|
||||
|
||||
def test_path_default_none(self) -> None:
|
||||
from agent_framework import SkillScript
|
||||
|
||||
script = SkillScript(name="test", function=lambda: None)
|
||||
assert script.path is None
|
||||
|
||||
def test_path_set_explicitly(self) -> None:
|
||||
from agent_framework import SkillScript
|
||||
|
||||
script = SkillScript(name="gen.py", path="/skills/my-skill/scripts/gen.py")
|
||||
assert script.path == "/skills/my-skill/scripts/gen.py"
|
||||
|
||||
def test_create_with_function(self) -> None:
|
||||
from agent_framework import SkillScript
|
||||
|
||||
script = SkillScript(name="analyze", description="Run analysis", function=lambda: "result")
|
||||
assert script.name == "analyze"
|
||||
assert script.description == "Run analysis"
|
||||
assert script.function is not None
|
||||
|
||||
def test_accepts_kwargs_true_for_kwargs_function(self) -> None:
|
||||
from agent_framework import SkillScript
|
||||
|
||||
def func_with_kwargs(**kwargs: Any) -> str:
|
||||
return "result"
|
||||
|
||||
@@ -1880,8 +1908,6 @@ class TestSkillScript:
|
||||
assert script._accepts_kwargs is True
|
||||
|
||||
def test_accepts_kwargs_false_for_regular_function(self) -> None:
|
||||
from agent_framework import SkillScript
|
||||
|
||||
def func_no_kwargs(x: int = 0) -> str:
|
||||
return "result"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user