Skip to content

Commit eceea95

Browse files
authored
feat: Add Feature Gating configuration helpers. (#17524)
This PR introduces a foundational configuration layer for integrating General Feature gating into Google Cloud Python client libraries. ### Intent & Motivation We are about be embark on adding observability (o11y) to entire suite of Cloud SDK libraries. To enable this functionality on an opt-in basis we are prepping a general feature gating capability. While the initial push is towards enabling o11y, we recognize that this can and should be more general than that. ### Changes Included * **Configuration Resolution Logic**: Added the `resolve_feature_flag` function in the new `_feature_gating_helpers` package to resolve feature gates: Resolves settings in the following order of precedence: 1. Programmatic overrides in configurations (checks for feature_key values) 2. Language-wide Environment Variable: `GOOGLE_CLOUD_PYTHON_<FEATURE>_ENABLED` (natively checks for an `EXPERIMENTAL` token first e.g. `GOOGLE_CLOUD_[EXPERIMENTAL]_PYTHON_<FEATURE>_ENABLED`) 3. Default fallback * **Experimental Fallbacks**: Implemented fallback logic that automatically checks for `EXPERIMENTAL` prefixes within the environment variable definitions to support early-stage adoption and rollout phases safely. * **Test Coverage**: Provided test coverage using `pytest` for precedence rules and environmental scenarios.
1 parent 8ce495a commit eceea95

2 files changed

Lines changed: 363 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
"""Observability environment variable and client options resolution helpers."""
18+
19+
import os
20+
import warnings
21+
from typing import Any
22+
23+
# We import ClientOptions for type hinting
24+
from google.api_core.client_options import ClientOptions
25+
26+
# Allowed truthy and falsy patterns for environment variables
27+
_TRUTHY_VALUES = ("y", "yes", "t", "true", "on", "1")
28+
_FALSY_VALUES = ("n", "no", "f", "false", "off", "0")
29+
30+
31+
class FeatureGatingError(ValueError):
32+
"""Raised when feature gating resolution fails or is misconfigured."""
33+
34+
pass
35+
36+
37+
def _strtobool(val: str) -> bool | None:
38+
"""Convert a string representation of truth to a boolean."""
39+
clean_val = val.lower().strip()
40+
if not clean_val:
41+
return None
42+
if clean_val in _TRUTHY_VALUES:
43+
return True
44+
if clean_val in _FALSY_VALUES:
45+
return False
46+
raise ValueError(f"Invalid truth value: {val!r}")
47+
48+
49+
def _get_env_bool(name: str) -> bool | None:
50+
"""Retrieve the boolean value of an environment variable."""
51+
val = os.getenv(name)
52+
if val is None:
53+
return None
54+
try:
55+
return _strtobool(val)
56+
except ValueError as e:
57+
warnings.warn(f"Ignored invalid value for {name}: {e}", RuntimeWarning)
58+
return None
59+
60+
61+
def _has_feature_key(
62+
*, configuration: ClientOptions | dict[str, Any] | None, feature_key: str
63+
) -> bool:
64+
"""Checks if a specific feature key is present and not None in configuration."""
65+
if configuration is None:
66+
return False
67+
68+
if feature_key.startswith("__"):
69+
return False
70+
71+
if isinstance(configuration, dict):
72+
return configuration.get(feature_key) is not None
73+
74+
return getattr(configuration, feature_key, None) is not None
75+
76+
77+
def resolve_feature_flags(
78+
*,
79+
env_var: str,
80+
feature_key: str,
81+
configuration: ClientOptions | dict[str, Any] | None = None,
82+
) -> bool:
83+
"""Determines if a feature is enabled based on environment variables and configuration.
84+
85+
Behavior depends on whether the `env_var` name contains "EXPERIMENTAL":
86+
87+
- **Experimental Path** (env_var contains "EXPERIMENTAL"):
88+
Strict control. Requires the environment variable to be explicitly 'true'.
89+
If a programmatic feature key is passed but the environment variable is not 'true',
90+
raises FeatureGatingError (Fail Fast).
91+
92+
- **GA Path** (env_var does not contain "EXPERIMENTAL"):
93+
Standard precedence. Enabled if a programmatic feature key is passed,
94+
otherwise falls back to the environment variable value.
95+
96+
Args:
97+
env_var: The name of the environment variable controlling this feature.
98+
feature_key: The key in configuration/attributes for the programmatic configuration.
99+
configuration: Optional. A dictionary or object containing client configuration.
100+
101+
Returns:
102+
bool: True if the feature is resolved to enabled, False otherwise.
103+
104+
Raises:
105+
FeatureGatingError: If a feature key is provided for an experimental feature without enabling the experimental environment variable.
106+
"""
107+
108+
# Check for programmatic feature configuration
109+
has_feature_key = _has_feature_key(
110+
configuration=configuration, feature_key=feature_key
111+
)
112+
113+
# Read environment variable
114+
env_var_setting = _get_env_bool(env_var)
115+
116+
# EXPERIMENTAL PATH:
117+
# Resolution Hierarchy:
118+
# 1. EXPERIMENTAL Environment Variable
119+
# 2. Fail Fast if Feature Key present but EXPERIMENTAL Environment Variable is not enabled
120+
if "EXPERIMENTAL" in env_var:
121+
# Fail Fast if feature key present but experimental environment variable is not enabled
122+
if env_var_setting is not True and has_feature_key:
123+
raise FeatureGatingError(
124+
f"Experimental feature requires {env_var} to be set to 'true' to use programmatic configuration."
125+
)
126+
127+
return bool(env_var_setting)
128+
129+
# GENERAL AVAILABILITY PATH:
130+
# Resolution Hierarchy:
131+
# 1. Programmatic Configuration (Feature Key)
132+
# 2. Environment Variable
133+
134+
# Check Programmatic Configuration
135+
if has_feature_key:
136+
return True
137+
138+
# Check Environment Variable
139+
return bool(env_var_setting)
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Copyright 2026 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 pytest
16+
from google.api_core import _feature_gating_helpers
17+
from google.api_core._feature_gating_helpers import (
18+
FeatureGatingError,
19+
_get_env_bool,
20+
_strtobool,
21+
)
22+
23+
24+
@pytest.mark.parametrize(
25+
"value,expected",
26+
[
27+
# Truthy values
28+
("y", True),
29+
("yes", True),
30+
("t", True),
31+
("true", True),
32+
("on", True),
33+
("1", True),
34+
(" True ", True),
35+
# Falsy values
36+
("n", False),
37+
("no", False),
38+
("f", False),
39+
("false", False),
40+
("off", False),
41+
("0", False),
42+
(" FALSE ", False),
43+
# Empty string
44+
("", None),
45+
],
46+
)
47+
def test_strtobool(value, expected):
48+
assert _strtobool(value) is expected
49+
50+
51+
def test_strtobool_invalid():
52+
with pytest.raises(ValueError):
53+
_strtobool("invalid")
54+
55+
56+
def test_get_env_bool(monkeypatch):
57+
monkeypatch.setenv("TEST_VAR", "true")
58+
assert _get_env_bool("TEST_VAR") is True
59+
60+
monkeypatch.setenv("TEST_VAR", "invalid")
61+
import pytest
62+
63+
with pytest.warns(RuntimeWarning, match="Ignored invalid value"):
64+
assert _get_env_bool("TEST_VAR") is None
65+
66+
monkeypatch.delenv("TEST_VAR", raising=False)
67+
assert _get_env_bool("TEST_VAR") is None
68+
69+
70+
def test_resolve_feature_flags_ga_enabled_via_env(monkeypatch):
71+
"""Verify that a GA feature is enabled if its environment variable is True."""
72+
# Setup: We pass a GA environment variable set to True
73+
monkeypatch.setenv("GOOGLE_SDK_PYTHON_TRACING_ENABLED", "true")
74+
75+
# Action
76+
result = _feature_gating_helpers.resolve_feature_flags(
77+
env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED",
78+
feature_key="tracer_provider",
79+
configuration=None,
80+
)
81+
82+
# Assertion
83+
assert result is True
84+
85+
86+
@pytest.mark.parametrize("exp_env_state", [None, "false"], ids=["missing", "disabled"])
87+
def test_resolve_feature_flags_exp_blocked_with_feature_key_fails_fast(
88+
monkeypatch, exp_env_state
89+
):
90+
"""Verify that passing a feature_key to an experimental feature raises FeatureGatingError if the experimental environment variable is disabled or missing."""
91+
# Setup: Experimental env var is set to exp_env_state (None means not set)
92+
if exp_env_state is not None:
93+
monkeypatch.setenv(
94+
"GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", exp_env_state
95+
)
96+
else:
97+
monkeypatch.delenv(
98+
"GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", raising=False
99+
)
100+
configuration = {"tracer_provider": object()}
101+
102+
# Action & Assertion
103+
with pytest.raises(FeatureGatingError, match="Experimental feature"):
104+
_feature_gating_helpers.resolve_feature_flags(
105+
env_var="GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED",
106+
feature_key="tracer_provider",
107+
configuration=configuration,
108+
)
109+
110+
111+
def test_resolve_feature_flags_exp_enabled_with_feature_key(monkeypatch):
112+
"""Verify that experimental feature is enabled if the experimental environment variable is enabled and a feature_key is provided."""
113+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
114+
configuration = {"tracer_provider": object()}
115+
116+
result = _feature_gating_helpers.resolve_feature_flags(
117+
env_var="GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED",
118+
feature_key="tracer_provider",
119+
configuration=configuration,
120+
)
121+
assert result is True
122+
123+
124+
def test_resolve_feature_flags_exp_enabled_without_feature_key(monkeypatch):
125+
"""Verify that experimental feature is enabled if the experimental environment variable is enabled and NO feature_key is provided."""
126+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "true")
127+
128+
result = _feature_gating_helpers.resolve_feature_flags(
129+
env_var="GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED",
130+
feature_key="tracer_provider",
131+
configuration=None,
132+
)
133+
assert result is True
134+
135+
136+
def test_resolve_feature_flags_exp_disabled_without_feature_key(monkeypatch):
137+
"""Verify that experimental feature is disabled if the experimental environment variable is disabled and NO feature_key is provided."""
138+
monkeypatch.setenv("GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED", "false")
139+
140+
result = _feature_gating_helpers.resolve_feature_flags(
141+
env_var="GOOGLE_SDK_EXPERIMENTAL_PYTHON_TRACING_ENABLED",
142+
feature_key="tracer_provider",
143+
configuration=None,
144+
)
145+
assert result is False
146+
147+
148+
def test_resolve_feature_flags_ga_enabled_via_feature_key(monkeypatch):
149+
"""Verify that a GA feature is enabled if a feature_key is provided, ignoring the environment variable."""
150+
# Env var is False, but feature_key is present in configuration
151+
monkeypatch.setenv("GOOGLE_SDK_PYTHON_TRACING_ENABLED", "false")
152+
configuration = {"tracer_provider": object()}
153+
154+
result = _feature_gating_helpers.resolve_feature_flags(
155+
env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED",
156+
feature_key="tracer_provider",
157+
configuration=configuration,
158+
)
159+
assert result is True
160+
161+
162+
@pytest.mark.parametrize(
163+
"env_val", [None, "false"], ids=["env_not_set", "env_explicit_false"]
164+
)
165+
def test_resolve_feature_flags_ga_fallback_to_false(monkeypatch, env_val):
166+
"""Verify that a GA feature is disabled if neither a feature_key is provided nor the environment variable is enabled."""
167+
if env_val is not None:
168+
monkeypatch.setenv("GOOGLE_SDK_PYTHON_TRACING_ENABLED", env_val)
169+
else:
170+
monkeypatch.delenv("GOOGLE_SDK_PYTHON_TRACING_ENABLED", raising=False)
171+
result = _feature_gating_helpers.resolve_feature_flags(
172+
env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED",
173+
feature_key="tracer_provider",
174+
configuration=None,
175+
)
176+
assert result is False
177+
178+
179+
class _MockOptions:
180+
def __init__(self):
181+
self.other_option = "value"
182+
183+
184+
@pytest.mark.parametrize(
185+
"configuration",
186+
[
187+
{"other_option": "value"},
188+
_MockOptions(),
189+
],
190+
ids=["dict_without_key", "object_without_key"],
191+
)
192+
def test_resolve_feature_flags_options_without_key(configuration):
193+
"""Verify behavior when configuration is present but missing the feature key."""
194+
# GA Path: should fall through to env var / fallback
195+
result = _feature_gating_helpers.resolve_feature_flags(
196+
env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED",
197+
feature_key="tracer_provider",
198+
configuration=configuration,
199+
)
200+
assert result is False
201+
202+
203+
def test_resolve_feature_flags_rejects_dunder_keys(monkeypatch):
204+
"""Verify that dunder keys are rejected early in _has_feature_key."""
205+
# We use a dunder key that exists on all objects (__class__)
206+
# If the guardrail is missing, getattr might return it and return True
207+
configuration = {"__class__": "some_value"}
208+
209+
result = _feature_gating_helpers.resolve_feature_flags(
210+
env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED",
211+
feature_key="__class__",
212+
configuration=configuration,
213+
)
214+
assert result is False
215+
216+
class MockWithClass:
217+
__class__ = "some_value"
218+
219+
result = _feature_gating_helpers.resolve_feature_flags(
220+
env_var="GOOGLE_SDK_PYTHON_TRACING_ENABLED",
221+
feature_key="__class__",
222+
configuration=MockWithClass(),
223+
)
224+
assert result is False

0 commit comments

Comments
 (0)