Skip to content

Commit 871da73

Browse files
xuanyang15copybara-github
authored andcommitted
feat: Add feature decorator for the feature registry system
Co-authored-by: Xuan Yang <xygoogle@google.com> PiperOrigin-RevId: 832503990
1 parent 9211f4c commit 871da73

File tree

5 files changed

+353
-15
lines changed

5 files changed

+353
-15
lines changed

src/google/adk/features/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,17 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
15+
from ._feature_decorator import experimental
16+
from ._feature_decorator import stable
17+
from ._feature_decorator import working_in_progress
18+
from ._feature_registry import FeatureName
19+
from ._feature_registry import is_feature_enabled
20+
21+
__all__ = [
22+
"experimental",
23+
"stable",
24+
"working_in_progress",
25+
"FeatureName",
26+
"is_feature_enabled",
27+
]
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
import functools
18+
from typing import Callable
19+
from typing import cast
20+
from typing import TypeVar
21+
from typing import Union
22+
23+
from ._feature_registry import _get_feature_config
24+
from ._feature_registry import _register_feature
25+
from ._feature_registry import FeatureConfig
26+
from ._feature_registry import FeatureName
27+
from ._feature_registry import FeatureStage
28+
from ._feature_registry import is_feature_enabled
29+
30+
T = TypeVar("T", bound=Union[Callable, type])
31+
32+
33+
def _make_feature_decorator(
34+
*,
35+
feature_name: FeatureName,
36+
feature_stage: FeatureStage,
37+
default_on: bool = False,
38+
) -> Callable[[T], T]:
39+
"""Decorator for experimental features.
40+
41+
Args:
42+
feature_name: The name of the feature to decorate.
43+
feature_stage: The stage of the feature.
44+
default_on: Whether the feature is enabled by default.
45+
46+
Returns:
47+
A decorator that checks if the feature is enabled and raises an error if
48+
not.
49+
"""
50+
config = _get_feature_config(feature_name)
51+
if config is None:
52+
config = FeatureConfig(feature_stage, default_on=default_on)
53+
_register_feature(feature_name, config)
54+
55+
if config.stage != feature_stage:
56+
raise ValueError(
57+
f"Feature '{feature_name}' is being defined with stage"
58+
f" '{feature_stage}', but it was previously registered with stage"
59+
f" '{config.stage}'. Please ensure the feature is consistently defined."
60+
)
61+
62+
def decorator(obj: T) -> T:
63+
def check_feature_enabled():
64+
if not is_feature_enabled(feature_name):
65+
raise RuntimeError(f"Feature {feature_name} is not enabled.")
66+
67+
if isinstance(obj, type): # decorating a class
68+
original_init = obj.__init__
69+
70+
@functools.wraps(original_init)
71+
def new_init(*args, **kwargs):
72+
check_feature_enabled()
73+
return original_init(*args, **kwargs)
74+
75+
obj.__init__ = new_init
76+
return cast(T, obj)
77+
elif isinstance(obj, Callable): # decorating a function
78+
79+
@functools.wraps(obj)
80+
def wrapper(*args, **kwargs):
81+
check_feature_enabled()
82+
return obj(*args, **kwargs)
83+
84+
return cast(T, wrapper)
85+
86+
else:
87+
raise TypeError(
88+
"@experimental can only be applied to classes or callable objects"
89+
)
90+
91+
return decorator
92+
93+
94+
def working_in_progress(feature_name: FeatureName) -> Callable[[T], T]:
95+
"""Decorator for working in progress features."""
96+
return _make_feature_decorator(
97+
feature_name=feature_name,
98+
feature_stage=FeatureStage.WIP,
99+
default_on=False,
100+
)
101+
102+
103+
def experimental(feature_name: FeatureName) -> Callable[[T], T]:
104+
"""Decorator for experimental features."""
105+
return _make_feature_decorator(
106+
feature_name=feature_name,
107+
feature_stage=FeatureStage.EXPERIMENTAL,
108+
default_on=False,
109+
)
110+
111+
112+
def stable(feature_name: FeatureName) -> Callable[[T], T]:
113+
"""Decorator for stable features."""
114+
return _make_feature_decorator(
115+
feature_name=feature_name,
116+
feature_stage=FeatureStage.STABLE,
117+
default_on=True,
118+
)

src/google/adk/features/feature_registry.py renamed to src/google/adk/features/_feature_registry.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,13 @@ def _execute_agent_loop():
125125
raise ValueError(f"Feature {feature_name} is not registered.")
126126

127127
# Check environment variables first (highest priority)
128-
enable_var = f"ADK_ENABLE_{feature_name}"
129-
disable_var = f"ADK_DISABLE_{feature_name}"
128+
feature_name_str = (
129+
feature_name.value
130+
if isinstance(feature_name, FeatureName)
131+
else feature_name
132+
)
133+
enable_var = f"ADK_ENABLE_{feature_name_str}"
134+
disable_var = f"ADK_DISABLE_{feature_name_str}"
130135
if is_env_enabled(enable_var):
131136
if config.stage != FeatureStage.STABLE:
132137
_emit_non_stable_warning_once(feature_name, config.stage)
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import os
16+
import warnings
17+
18+
from google.adk.features._feature_decorator import experimental
19+
from google.adk.features._feature_decorator import stable
20+
from google.adk.features._feature_decorator import working_in_progress
21+
from google.adk.features._feature_registry import _FEATURE_REGISTRY
22+
from google.adk.features._feature_registry import _get_feature_config
23+
from google.adk.features._feature_registry import _register_feature
24+
from google.adk.features._feature_registry import _WARNED_FEATURES
25+
from google.adk.features._feature_registry import FeatureConfig
26+
from google.adk.features._feature_registry import FeatureStage
27+
import pytest
28+
29+
30+
@working_in_progress("WIP_CLASS")
31+
class IncompleteFeature:
32+
33+
def run(self):
34+
return "running"
35+
36+
37+
@working_in_progress("WIP_FUNCTION")
38+
def wip_function():
39+
return "executing"
40+
41+
42+
@experimental("EXPERIMENTAL_CLASS")
43+
class ExperimentalClass:
44+
45+
def run(self):
46+
return "running"
47+
48+
49+
@experimental("EXPERIMENTAL_FUNCTION")
50+
def experimental_function():
51+
return "executing"
52+
53+
54+
@stable("STABLE_CLASS")
55+
class StableClass:
56+
57+
def run(self):
58+
return "running"
59+
60+
61+
@stable("STABLE_FUNCTION")
62+
def stable_function():
63+
return "executing"
64+
65+
66+
@pytest.fixture(autouse=True)
67+
def reset_env_and_registry(monkeypatch):
68+
"""Reset environment variables and registry before each test."""
69+
# Clean up environment variables
70+
for key in list(os.environ.keys()):
71+
if key.startswith("ADK_ENABLE_") or key.startswith("ADK_DISABLE_"):
72+
monkeypatch.delenv(key, raising=False)
73+
74+
# Add an existing feature to the registry
75+
_register_feature(
76+
"ENABLED_EXPERIMENTAL_FEATURE",
77+
FeatureConfig(FeatureStage.EXPERIMENTAL, default_on=True),
78+
)
79+
80+
_register_feature(
81+
"EXPERIMENTAL_FUNCTION",
82+
FeatureConfig(FeatureStage.EXPERIMENTAL, default_on=True),
83+
)
84+
85+
86+
def test_working_in_progress_stage_mismatch():
87+
"""Test that working_in_progress is used with a non-WIP stage."""
88+
try:
89+
90+
@working_in_progress("ENABLED_EXPERIMENTAL_FEATURE")
91+
def unused_function(): # pylint: disable=unused-variable
92+
return "unused"
93+
94+
assert False, "Expected ValueError to be raised."
95+
except ValueError as e:
96+
assert (
97+
"Feature 'ENABLED_EXPERIMENTAL_FEATURE' is being defined with stage"
98+
" 'FeatureStage.WIP', but it was previously registered with stage"
99+
" 'FeatureStage.EXPERIMENTAL'."
100+
in str(e)
101+
)
102+
103+
104+
def test_working_in_progress_class_raises_error():
105+
"""Test that WIP class raises RuntimeError by default."""
106+
107+
try:
108+
IncompleteFeature()
109+
assert False, "Expected RuntimeError to be raised."
110+
except RuntimeError as e:
111+
assert "Feature WIP_CLASS is not enabled." in str(e)
112+
113+
114+
def test_working_in_progress_class_bypass_with_env_var(monkeypatch):
115+
"""Test that WIP class can be bypassed with env var."""
116+
117+
monkeypatch.setenv("ADK_ENABLE_WIP_CLASS", "true")
118+
119+
with warnings.catch_warnings(record=True) as w:
120+
feature = IncompleteFeature()
121+
feature.run()
122+
assert len(w) == 1
123+
assert "[WIP] feature WIP_CLASS is enabled." in str(w[0].message)
124+
125+
126+
def test_working_in_progress_function_raises_error():
127+
"""Test that WIP function raises RuntimeError by default."""
128+
129+
try:
130+
wip_function()
131+
assert False, "Expected RuntimeError to be raised."
132+
except RuntimeError as e:
133+
assert "Feature WIP_FUNCTION is not enabled." in str(e)
134+
135+
136+
def test_working_in_progress_function_bypass_with_env_var(monkeypatch):
137+
"""Test that WIP function can be bypassed with env var."""
138+
139+
monkeypatch.setenv("ADK_ENABLE_WIP_FUNCTION", "true")
140+
141+
with warnings.catch_warnings(record=True) as w:
142+
wip_function()
143+
assert len(w) == 1
144+
assert "[WIP] feature WIP_FUNCTION is enabled." in str(w[0].message)
145+
146+
147+
def test_disabled_experimental_class_raises_error():
148+
"""Test that disabled experimental class raises RuntimeError by default."""
149+
150+
try:
151+
ExperimentalClass()
152+
assert False, "Expected RuntimeError to be raised."
153+
except RuntimeError as e:
154+
assert "Feature EXPERIMENTAL_CLASS is not enabled." in str(e)
155+
156+
157+
def test_disabled_experimental_class_bypass_with_env_var(monkeypatch):
158+
"""Test that disabled experimental class can be bypassed with env var."""
159+
160+
monkeypatch.setenv("ADK_ENABLE_EXPERIMENTAL_CLASS", "true")
161+
162+
with warnings.catch_warnings(record=True) as w:
163+
feature = ExperimentalClass()
164+
feature.run()
165+
assert len(w) == 1
166+
assert "[EXPERIMENTAL] feature EXPERIMENTAL_CLASS is enabled." in str(
167+
w[0].message
168+
)
169+
170+
171+
def test_enabled_experimental_function_does_not_raise_error():
172+
"""Test that enabled experimental function does not raise error."""
173+
174+
with warnings.catch_warnings(record=True) as w:
175+
experimental_function()
176+
assert len(w) == 1
177+
assert "[EXPERIMENTAL] feature EXPERIMENTAL_FUNCTION is enabled." in str(
178+
w[0].message
179+
)
180+
181+
182+
def test_enabled_experimental_function_disabled_by_env_var(monkeypatch):
183+
"""Test that enabled experimental function can be disabled by env var."""
184+
185+
monkeypatch.setenv("ADK_DISABLE_EXPERIMENTAL_FUNCTION", "true")
186+
187+
try:
188+
experimental_function()
189+
assert False, "Expected RuntimeError to be raised."
190+
except RuntimeError as e:
191+
assert "Feature EXPERIMENTAL_FUNCTION is not enabled." in str(e)
192+
193+
194+
def test_stable_class_does_not_raise_error_or_warn():
195+
"""Test that stable class does not raise error or warn."""
196+
197+
with warnings.catch_warnings(record=True) as w:
198+
StableClass().run()
199+
assert not w
200+
201+
202+
def test_stable_function_does_not_raise_error_or_warn():
203+
"""Test that stable function does not raise error or warn."""
204+
205+
with warnings.catch_warnings(record=True) as w:
206+
stable_function()
207+
assert not w

tests/unittests/features/test_feature_registry.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@
1717
import os
1818
import warnings
1919

20-
from google.adk.features.feature_registry import _FEATURE_REGISTRY
21-
from google.adk.features.feature_registry import _get_feature_config
22-
from google.adk.features.feature_registry import _register_feature
23-
from google.adk.features.feature_registry import _WARNED_FEATURES
24-
from google.adk.features.feature_registry import FeatureConfig
25-
from google.adk.features.feature_registry import FeatureStage
26-
from google.adk.features.feature_registry import is_feature_enabled
20+
from google.adk.features._feature_registry import _FEATURE_REGISTRY
21+
from google.adk.features._feature_registry import _get_feature_config
22+
from google.adk.features._feature_registry import _register_feature
23+
from google.adk.features._feature_registry import _WARNED_FEATURES
24+
from google.adk.features._feature_registry import FeatureConfig
25+
from google.adk.features._feature_registry import FeatureStage
26+
from google.adk.features._feature_registry import is_feature_enabled
2727
import pytest
2828

2929
FEATURE_CONFIG_WIP = FeatureConfig(FeatureStage.WIP, default_on=False)
@@ -44,17 +44,11 @@ def reset_env_and_registry(monkeypatch):
4444
if key.startswith("ADK_ENABLE_") or key.startswith("ADK_DISABLE_"):
4545
monkeypatch.delenv(key, raising=False)
4646

47-
# Clear registry (but keep it as a dict for adding test entries)
48-
_FEATURE_REGISTRY.clear()
49-
5047
# Reset warned features set
5148
_WARNED_FEATURES.clear()
5249

5350
yield
5451

55-
# Clean up after test
56-
_FEATURE_REGISTRY.clear()
57-
5852
# Reset warned features set
5953
_WARNED_FEATURES.clear()
6054

0 commit comments

Comments
 (0)