Skip to content

Commit 10484f6

Browse files
aapelivclaude
andcommitted
Move feature-flag evaluation onto context with OpenFeature-style API
Add get_boolean_value/get_string_value/get_integer_value/get_float_value/ get_object_value methods to CouchersContext, mirroring the OpenFeature evaluation API and requiring an in-code default per call. All methods (including boolean) now evaluate via the GrowthBook SDK's get_feature_value so the in-code default is honored when a flag isn't set up yet - the old check_gate used is_on(), which hardcoded False for missing flags. Relocate the type-only CouchersContext imports in db.py and sql.py under TYPE_CHECKING to avoid a context -> experimentation -> db/sql -> context import cycle. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 01ea3c7 commit 10484f6

5 files changed

Lines changed: 37 additions & 15 deletions

File tree

app/backend/src/couchers/context.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import grpc
44

5+
from couchers import experimentation
56
from couchers.i18n import LocalizationContext
67

78
if TYPE_CHECKING:
@@ -179,6 +180,22 @@ def token(self) -> str:
179180
def localization(self) -> LocalizationContext:
180181
return self.__localization
181182

183+
# Feature-flag evaluation methods mirror the OpenFeature evaluation API.
184+
def get_boolean_value(self, flag_key: str, default: bool) -> bool:
185+
return experimentation.evaluate_boolean(self, flag_key, default)
186+
187+
def get_string_value(self, flag_key: str, default: str) -> str:
188+
return experimentation.evaluate_value(self, flag_key, default)
189+
190+
def get_integer_value(self, flag_key: str, default: int) -> int:
191+
return experimentation.evaluate_value(self, flag_key, default)
192+
193+
def get_float_value(self, flag_key: str, default: float) -> float:
194+
return experimentation.evaluate_value(self, flag_key, default)
195+
196+
def get_object_value[T](self, flag_key: str, default: T) -> T:
197+
return experimentation.evaluate_value(self, flag_key, default)
198+
182199

183200
def make_interactive_context(
184201
grpc_context: grpc.ServicerContext,

app/backend/src/couchers/db.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from contextlib import contextmanager
77
from os import getpid
88
from threading import get_ident
9+
from typing import TYPE_CHECKING
910

1011
from alembic import command
1112
from alembic.config import Config
@@ -19,7 +20,6 @@
1920

2021
from couchers.config import config
2122
from couchers.constants import SERVER_THREADS, WORKER_THREADS
22-
from couchers.context import CouchersContext
2323
from couchers.models import (
2424
Cluster,
2525
ClusterRole,
@@ -33,6 +33,9 @@
3333
)
3434
from couchers.sql import where_users_column_visible
3535

36+
if TYPE_CHECKING:
37+
from couchers.context import CouchersContext
38+
3639
# Register psycopg (psycopg3) as the default driver for postgresql:// URLs
3740
# This must happen before any engine is created
3841
registry.register("postgresql", "sqlalchemy.dialects.postgresql.psycopg", "PGDialect_psycopg")

app/backend/src/couchers/experimentation.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -174,29 +174,32 @@ def on_experiment_viewed(experiment: Experiment, result: Result, **kwargs: Any)
174174
return gb
175175

176176

177-
def check_gate(context: CouchersContext, gate_name: str) -> bool:
177+
def evaluate_boolean(context: CouchersContext, flag_key: str, default: bool) -> bool:
178178
"""
179-
Check if a feature gate is enabled for the user in this context.
179+
Evaluate a boolean feature gate for the user in this context.
180180
181-
Returns False if experimentation is disabled, True if EXPERIMENTATION_PASS_ALL_GATES is set.
181+
Mirrors OpenFeature's get_boolean_value. Returns True if EXPERIMENTATION_PASS_ALL_GATES
182+
is set, the in-code default if experimentation is disabled or the flag isn't set up in
183+
GrowthBook, otherwise the gate's value. Goes through get_feature_value (not is_on) so the
184+
default is honored for not-yet-created flags.
182185
"""
183186
_check_initialized()
184187
if config["EXPERIMENTATION_PASS_ALL_GATES"]:
185188
return True
186189
if not config["EXPERIMENTATION_ENABLED"]:
187-
return False
188-
return _get_growthbook(context).is_on(gate_name)
190+
return default
191+
return bool(_get_growthbook(context).get_feature_value(flag_key, default))
189192

190193

191-
def get_feature_value[T](context: CouchersContext, feature_name: str, default: T) -> T:
194+
def evaluate_value[T](context: CouchersContext, flag_key: str, default: T) -> T:
192195
"""
193-
Get the value of a feature for the user in this context.
196+
Evaluate a non-boolean feature value for the user in this context.
194197
195-
Use this for non-boolean features: strings, numbers, dicts, experiment variations,
196-
dynamic configs - anything other than a simple on/off gate. The default's type
197-
determines the return type and is returned verbatim when experimentation is disabled.
198+
Mirrors OpenFeature's get_string_value/get_integer_value/get_float_value/get_object_value.
199+
The default's type determines the return type; it's returned verbatim when experimentation
200+
is disabled or the flag isn't set up in GrowthBook.
198201
"""
199202
_check_initialized()
200203
if not config["EXPERIMENTATION_ENABLED"]:
201204
return default
202-
return _get_growthbook(context).get_feature_value(feature_name, default) # type: ignore[no-any-return]
205+
return _get_growthbook(context).get_feature_value(flag_key, default) # type: ignore[no-any-return]

app/backend/src/couchers/servicers/account.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
verify_token,
2828
)
2929
from couchers.event_log import log_event
30-
from couchers.experimentation import check_gate
3130
from couchers.helpers.completed_profile import has_completed_profile
3231
from couchers.helpers.geoip import geoip_approximate_location
3332
from couchers.helpers.strong_verification import get_strong_verification_fields
@@ -169,7 +168,7 @@ def GetAccountInfo(
169168

170169
# Test experimentation integration - check if user is in the test gate
171170
# Create 'test_growthbook_integration' in GrowthBook to test
172-
test_gate = check_gate(context, "test_growthbook_integration")
171+
test_gate = context.get_boolean_value("test_growthbook_integration", default=False)
173172
logger.info(f"Experimentation gate 'test_growthbook_integration' for user {user.id}: {test_gate}")
174173

175174
should_show_donation_banner = DONATION_DRIVE_START is not None and (

app/backend/src/couchers/sql.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from sqlalchemy.orm import InstrumentedAttribute, aliased
55
from sqlalchemy.sql import Select, exists, union
66

7-
from couchers.context import CouchersContext
87
from couchers.models import (
98
ModerationState,
109
ModerationVisibility,
@@ -16,6 +15,7 @@
1615
from couchers.utils import is_valid_email, is_valid_user_id, is_valid_username
1716

1817
if TYPE_CHECKING:
18+
from couchers.context import CouchersContext
1919
from couchers.materialized_views import LiteUser
2020
from couchers.models.moderation import ModeratedContent
2121

0 commit comments

Comments
 (0)