From 8a3b45517310b3e9b3bf52b35adc56f4b0f93344 Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Sun, 7 Dec 2025 09:24:01 -0800 Subject: [PATCH 1/2] feat: add support for more detailed owner schema in flagpole --- src/flagpole/__init__.py | 26 +++++++++++++++++++++++--- src/flagpole/flagpole-schema.json | 26 +++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/flagpole/__init__.py b/src/flagpole/__init__.py index 5533f86828ae19..1ee57ff18dd1ff 100644 --- a/src/flagpole/__init__.py +++ b/src/flagpole/__init__.py @@ -87,13 +87,22 @@ def load_json_schema() -> dict[str, Any]: return data +@dataclasses.dataclass(frozen=True) +class OwnerInfo: + team: str + "The team that owns this feature." + + email: str | None = None + "The email address of the owner." + + @dataclasses.dataclass(frozen=True) class Feature: name: str "The feature name." - owner: str - "The owner of this feature. Either an email address or team name, preferably." + owner: str | OwnerInfo + "The owner of this feature." enabled: bool = dataclasses.field(default=True) "Whether or not the feature is enabled." @@ -133,9 +142,20 @@ def from_feature_dictionary(cls, name: str, config_dict: dict[str, Any]) -> Feat raise InvalidFeatureFlagConfiguration("Feature has no segments defined") try: segments = [Segment.from_dict(segment) for segment in segment_data] + + raw_owner = config_dict.get("owner", "") + owner: str | OwnerInfo + if isinstance(raw_owner, dict): + owner = OwnerInfo( + team=raw_owner["team"], + email=raw_owner.get("email"), + ) + else: + owner = str(raw_owner) + feature = cls( name=name, - owner=str(config_dict.get("owner", "")), + owner=owner, enabled=bool(config_dict.get("enabled", True)), created_at=str(config_dict.get("created_at")), segments=segments, diff --git a/src/flagpole/flagpole-schema.json b/src/flagpole/flagpole-schema.json index 3fd7da4dcca0c5..dd6619b85f790b 100644 --- a/src/flagpole/flagpole-schema.json +++ b/src/flagpole/flagpole-schema.json @@ -10,9 +10,29 @@ }, "owner": { "title": "Owner", - "description": "The owner of this feature. Either an email address or team name, preferably.", - "minLength": 1, - "type": "string" + "description": "The owner of this feature.", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "object", + "properties": { + "team": { + "type": "string", + "minLength": 1, + "description": "The team that owns this feature." + }, + "email": { + "type": ["string", "null"], + "description": "The email address of the owner. Not required." + } + }, + "required": ["team"], + "additionalProperties": false + } + ] }, "segments": { "title": "Segments", From 2113a04b867f47bb26f3b6ff43fee0739839c7bd Mon Sep 17 00:00:00 2001 From: Charlie Luo Date: Mon, 8 Dec 2025 09:17:21 -0800 Subject: [PATCH 2/2] test: add test for OwnerInfo --- src/flagpole/__init__.py | 1 + tests/flagpole/test_flagpole_eval.py | 62 +++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/flagpole/__init__.py b/src/flagpole/__init__.py index 1ee57ff18dd1ff..43aa7451902934 100644 --- a/src/flagpole/__init__.py +++ b/src/flagpole/__init__.py @@ -218,6 +218,7 @@ def to_json_str(self) -> str: __all__ = [ "Feature", + "OwnerInfo", "InvalidFeatureFlagConfiguration", "ContextBuilder", "EvaluationContext", diff --git a/tests/flagpole/test_flagpole_eval.py b/tests/flagpole/test_flagpole_eval.py index 4f65bcc0b74aac..293f8ef8b16a75 100644 --- a/tests/flagpole/test_flagpole_eval.py +++ b/tests/flagpole/test_flagpole_eval.py @@ -5,7 +5,7 @@ import pytest import yaml -from flagpole import Feature +from flagpole import Feature, OwnerInfo from flagpole.conditions import EqualsCondition, Segment from flagpole.evaluation_context import EvaluationContext from flagpole.flagpole_eval import evaluate_flag, get_arguments, read_feature @@ -163,6 +163,66 @@ def test_read_feature_missing_flag(self): Path(temp_file).unlink() +class TestOwnerInfo: + """Test OwnerInfo dataclass and Feature with OwnerInfo owner.""" + + def test_feature_with_owner_info(self): + """Test creating a Feature with OwnerInfo as owner.""" + owner = OwnerInfo(team="test-team", email="owner@sentry.io") + feature = Feature( + name="test-feature", + owner=owner, + enabled=True, + segments=[Segment(name="test-segment", rollout=100, conditions=[])], + ) + + assert isinstance(feature.owner, OwnerInfo) + assert feature.owner.team == "test-team" + assert feature.owner.email == "owner@sentry.io" + + def test_feature_with_owner_info_no_email(self): + """Test creating a Feature with OwnerInfo without email.""" + owner = OwnerInfo(team="test-team") + feature = Feature( + name="test-feature", + owner=owner, + enabled=True, + segments=[Segment(name="test-segment", rollout=100, conditions=[])], + ) + + assert isinstance(feature.owner, OwnerInfo) + assert feature.owner.team == "test-team" + assert feature.owner.email is None + + def test_feature_from_dict_with_owner_object(self): + """Test parsing a Feature from dict with owner as object.""" + config_dict = { + "enabled": True, + "owner": {"team": "test-team", "email": "owner@sentry.io"}, + "segments": [{"name": "test-segment", "rollout": 100, "conditions": []}], + } + + feature = Feature.from_feature_dictionary("test-feature", config_dict) + + assert isinstance(feature.owner, OwnerInfo) + assert feature.owner.team == "test-team" + assert feature.owner.email == "owner@sentry.io" + + def test_feature_from_dict_with_owner_object_no_email(self): + """Test parsing a Feature from dict with owner object without email.""" + config_dict = { + "enabled": True, + "owner": {"team": "test-team"}, + "segments": [{"name": "test-segment", "rollout": 100, "conditions": []}], + } + + feature = Feature.from_feature_dictionary("test-feature", config_dict) + + assert isinstance(feature.owner, OwnerInfo) + assert feature.owner.team == "test-team" + assert feature.owner.email is None + + class TestEvaluateFlag: """Test evaluate_flag() function for different flag scenarios."""