Skip to content

Commit

Permalink
Add FeatureFlagDelegate
Browse files Browse the repository at this point in the history
  • Loading branch information
imjoehaines committed Jul 21, 2023
1 parent 93a0b6d commit 8f7f9b6
Show file tree
Hide file tree
Showing 2 changed files with 356 additions and 2 deletions.
42 changes: 41 additions & 1 deletion bugsnag/feature_flags.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Dict, Union
from collections import OrderedDict
from typing import Dict, List, Union


class FeatureFlag:
Expand Down Expand Up @@ -46,3 +47,42 @@ def _coerce_variant(
return str(variant)
except Exception:
return None


class FeatureFlagDelegate:
def __init__(self):
self._storage = OrderedDict()

def add(
self,
name: Union[str, bytes],
variant: Union[None, str, bytes]
) -> None:
flag = FeatureFlag(name, variant)

if flag.is_valid():
self._storage[flag.name] = flag

def merge(self, flags: List[FeatureFlag]) -> None:
for flag in flags:
if isinstance(flag, FeatureFlag) and flag.is_valid():
self._storage[flag.name] = flag

def remove(self, name: Union[str, bytes]) -> None:
if name in self._storage:
del self._storage[name]

def clear(self) -> None:
self._storage.clear()

def copy(self) -> 'FeatureFlagDelegate':
copy = FeatureFlagDelegate()
copy._storage = self._storage.copy()

return copy

def to_list(self) -> List[FeatureFlag]:
return list(self._storage.values())

def to_json(self) -> List[Dict[str, Union[str, bytes]]]:
return [flag.to_dict() for flag in self.to_list()]
316 changes: 315 additions & 1 deletion tests/test_feature_flags.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from bugsnag.feature_flags import FeatureFlag
from bugsnag.feature_flags import FeatureFlag, FeatureFlagDelegate
import pytest


class Unstringable:
Expand All @@ -9,6 +10,17 @@ def __repr__(self):
raise Exception('nope')


_invalid_names = [
(None,),
(True,),
(False,),
(1234,),
([1, 2, 3],),
({'a': 1, 'b': 2},),
('',)
]


def test_feature_flag_has_name_and_variant():
flag = FeatureFlag('abc', 'xyz')

Expand Down Expand Up @@ -78,3 +90,305 @@ def test_a_feature_flag_with_none_name_is_not_valid():
flag = FeatureFlag(None)

assert flag.is_valid() is False


def test_delegate_contains_no_flags_by_default():
delegate = FeatureFlagDelegate()

assert delegate.to_list() == []
assert delegate.to_json() == []


def test_delegate_does_not_get_mutated_after_being_copied():
delegate1 = FeatureFlagDelegate()
delegate1.add('abc', '123')

delegate2 = delegate1.copy()
delegate2.add('xyz', '456')

delegate3 = delegate2.copy()
delegate3.clear()

assert delegate1.to_json() == [{'name': 'abc', 'variant': '123'}]
assert delegate2.to_json() == [
{'name': 'abc', 'variant': '123'},
{'name': 'xyz', 'variant': '456'},
]
assert delegate3.to_json() == []


def test_delegate_can_add_flags_individually():
delegate = FeatureFlagDelegate()
delegate.add('abc', 'xyz')
delegate.add('another', None)
delegate.add('again', 123)

assert delegate.to_json() == [
{'name': 'abc', 'variant': 'xyz'},
{'name': 'another'},
{'name': 'again', 'variant': '123'}
]


def test_delegate_add_replaces_by_name_when_the_original_has_no_variant():
delegate = FeatureFlagDelegate()
delegate.add('abc', None)
delegate.add('another', None)
delegate.add('abc', 123)

assert delegate.to_json() == [
{'name': 'abc', 'variant': '123'},
{'name': 'another'}
]


def test_delegate_add_replaces_by_name_when_the_replacement_has_no_variant():
delegate = FeatureFlagDelegate()
delegate.add('abc', '123')
delegate.add('another', None)
delegate.add('abc', None)

assert delegate.to_json() == [
{'name': 'abc'},
{'name': 'another'}
]


def test_delegate_add_replaces_by_name_when_both_have_variants():
delegate = FeatureFlagDelegate()
delegate.add('abc', '123')
delegate.add('another', None)
delegate.add('abc', '456')

assert delegate.to_json() == [
{'name': 'abc', 'variant': '456'},
{'name': 'another'}
]


@pytest.mark.parametrize('invalid_name', _invalid_names)
def test_delegate_add_add_drops_flag_when_name_is_invalid(invalid_name):
delegate = FeatureFlagDelegate()
delegate.add('abc', '123')
delegate.add(invalid_name, None)

assert delegate.to_json() == [
{'name': 'abc', 'variant': '123'}
]


def test_delegate_can_add_multiple_flags_at_once():
delegate = FeatureFlagDelegate()
delegate.merge([
FeatureFlag('a', 'xyz'),
FeatureFlag('b'),
FeatureFlag('c', '111'),
FeatureFlag('d'),
])

assert delegate.to_json() == [
{'name': 'a', 'variant': 'xyz'},
{'name': 'b'},
{'name': 'c', 'variant': '111'},
{'name': 'd'}
]


def test_delegate_can_merge_new_flags_with_existing_ones():
delegate = FeatureFlagDelegate()
delegate.merge([
FeatureFlag('a', 'xyz'),
FeatureFlag('b'),
FeatureFlag('c', '111'),
FeatureFlag('d'),
])

delegate.merge([
FeatureFlag('e'),
FeatureFlag('a'),
FeatureFlag('d', 'xyz'),
])

assert delegate.to_json() == [
{'name': 'a'},
{'name': 'b'},
{'name': 'c', 'variant': '111'},
{'name': 'd', 'variant': 'xyz'},
{'name': 'e'}
]


def test_delegate_merge_replaces_by_name_when_the_original_has_no_variant():
delegate = FeatureFlagDelegate()

delegate.add('a', None)
delegate.merge([
FeatureFlag('a', 'xyz'),
FeatureFlag('b'),
FeatureFlag('c', '111'),
FeatureFlag('d'),
])

assert delegate.to_json() == [
{'name': 'a', 'variant': 'xyz'},
{'name': 'b'},
{'name': 'c', 'variant': '111'},
{'name': 'd'}
]


def test_delegate_merge_replaces_by_name_when_the_replacement_has_no_variant():
delegate = FeatureFlagDelegate()

delegate.add('a', 'xyz')
delegate.merge([
FeatureFlag('a', None),
FeatureFlag('b'),
FeatureFlag('c', '111'),
FeatureFlag('d'),
])

assert delegate.to_json() == [
{'name': 'a'},
{'name': 'b'},
{'name': 'c', 'variant': '111'},
{'name': 'd'}
]


def test_delegate_merge_replaces_by_name_when_both_have_variants():
delegate = FeatureFlagDelegate()

delegate.add('a', 'xyz')
delegate.merge([
FeatureFlag('a', 'abc'),
FeatureFlag('b'),
FeatureFlag('c', '111'),
FeatureFlag('d'),
])

assert delegate.to_json() == [
{'name': 'a', 'variant': 'abc'},
{'name': 'b'},
{'name': 'c', 'variant': '111'},
{'name': 'd'}
]


def test_delegate_merge_ignores_anything_that_isnt_a_feature_flag_instance():
delegate = FeatureFlagDelegate()

delegate.merge([
FeatureFlag('a', None),
1234,
FeatureFlag('b'),
'hello',
FeatureFlag('c', '111'),
Exception(':)'),
FeatureFlag('d'),
None
])

assert delegate.to_json() == [
{'name': 'a'},
{'name': 'b'},
{'name': 'c', 'variant': '111'},
{'name': 'd'}
]


@pytest.mark.parametrize('invalid_name', _invalid_names)
def test_delegate_merge_drops_flag_when_name_is_invalid(invalid_name):
delegate = FeatureFlagDelegate()
delegate.merge([
FeatureFlag('abc', '123'),
FeatureFlag(invalid_name, '456'),
FeatureFlag('xyz', '789')
])

assert delegate.to_json() == [
{'name': 'abc', 'variant': '123'},
{'name': 'xyz', 'variant': '789'}
]


def test_delegate_can_remove_flags_by_name():
delegate = FeatureFlagDelegate()
delegate.merge([
FeatureFlag('abc', '123'),
FeatureFlag('to be removed', '456'),
FeatureFlag('xyz', '789')
])

delegate.remove('to be removed')

assert delegate.to_json() == [
{'name': 'abc', 'variant': '123'},
{'name': 'xyz', 'variant': '789'}
]


def test_delegate_remove_does_nothing_when_no_flag_has_the_given_name():
delegate = FeatureFlagDelegate()
delegate.merge([
FeatureFlag('abc', '123'),
FeatureFlag('to be kept', '456'),
FeatureFlag('xyz', '789')
])

delegate.remove('to be removed')

assert delegate.to_json() == [
{'name': 'abc', 'variant': '123'},
{'name': 'to be kept', 'variant': '456'},
{'name': 'xyz', 'variant': '789'}
]


def test_delegate_can_remove_all_flags_at_once():
delegate = FeatureFlagDelegate()
delegate.merge([
FeatureFlag('abc', '123'),
FeatureFlag('to be kept', '456'),
FeatureFlag('xyz', '789')
])

delegate.clear()

assert delegate.to_json() == []


def test_delegate_clear_does_nothing_when_there_are_no_flags():
delegate = FeatureFlagDelegate()
delegate.clear()

assert delegate.to_json() == []


def test_delegate_to_list_returns_a_list_of_feature_flags():
delegate = FeatureFlagDelegate()

flag1 = FeatureFlag('a')
flag2 = FeatureFlag('b')
flag3 = FeatureFlag('c')

delegate.merge([flag1, flag2, flag3])

assert delegate.to_list() == [flag1, flag2, flag3]


def test_delegate_can_be_mutated_without_affecting_the_internal_storage():
delegate = FeatureFlagDelegate()

flag1 = FeatureFlag('a')
flag2 = FeatureFlag('b')

delegate.merge([flag1, flag2])

flags = delegate.to_list()
flags.pop()
flags.append(1234)
flags.append(5678)

assert flags == [flag1, 1234, 5678]
assert delegate.to_list() == [flag1, flag2]

0 comments on commit 8f7f9b6

Please sign in to comment.