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:
Eduard van Valkenburg
2026-03-31 21:40:08 +02:00
committed by GitHub
Unverified
parent 9c9d81d8b6
commit 55b6e7a9f4
19 changed files with 1220 additions and 60 deletions
+10 -3
View File
@@ -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"
+41 -15
View File
@@ -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"