From 407734130b16e385729adafe14954627a0325f00 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Fri, 29 Aug 2025 15:36:14 -0400 Subject: [PATCH 1/4] chore: Add early support for FDv2-based test data source --- .../impl/integrations/test_datav2/__init__.py | 1 + .../test_datav2/test_data_sourcev2.py | 203 +++++ ldclient/integrations/test_datav2.py | 706 ++++++++++++++++++ .../integrations/test_test_data_sourcev2.py | 449 +++++++++++ 4 files changed, 1359 insertions(+) create mode 100644 ldclient/impl/integrations/test_datav2/__init__.py create mode 100644 ldclient/impl/integrations/test_datav2/test_data_sourcev2.py create mode 100644 ldclient/integrations/test_datav2.py create mode 100644 ldclient/testing/integrations/test_test_data_sourcev2.py diff --git a/ldclient/impl/integrations/test_datav2/__init__.py b/ldclient/impl/integrations/test_datav2/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/ldclient/impl/integrations/test_datav2/__init__.py @@ -0,0 +1 @@ + diff --git a/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py new file mode 100644 index 00000000..c644a626 --- /dev/null +++ b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py @@ -0,0 +1,203 @@ +import threading +import time +from queue import Empty, Queue +from typing import Generator + +from ldclient.impl.datasystem import BasisResult, Update +from ldclient.impl.datasystem.protocolv2 import ( + Basis, + ChangeSetBuilder, + IntentCode, + ObjectKind, + Selector +) +from ldclient.impl.util import _Fail, _Success, current_time_millis +from ldclient.interfaces import ( + DataSourceErrorInfo, + DataSourceErrorKind, + DataSourceState +) + + +class _TestDataSourceV2: + """ + Internal implementation of both Initializer and Synchronizer protocols for TestDataV2. + + This component bridges the test data management in TestDataV2 with the FDv2 protocol + interfaces. Each instance implements both Initializer and Synchronizer protocols + and receives change notifications for dynamic updates. + """ + + def __init__(self, test_data): + self._test_data = test_data + self._closed = False + self._update_queue = Queue() + self._lock = threading.Lock() + + # Always register for change notifications + self._test_data._add_instance(self) + + # Locking strategy: + # The threading.Lock instance (_lock) ensures thread safety for shared resources: + # - Used in `fetch` and `close` to prevent concurrent modification of `_closed`. + # - Added to `upsert_flag` to address potential race conditions. + # - The `sync` method relies on Queue's thread-safe properties for updates. + + def fetch(self) -> BasisResult: + """ + Implementation of the Initializer.fetch method. + + Returns the current test data as a Basis for initial data loading. + """ + try: + with self._lock: + if self._closed: + return _Fail("TestDataV2 source has been closed") + + # Get all current flags from test data + init_data = self._test_data._make_init_data() + version = self._test_data._get_version() + + # Build a full transfer changeset + builder = ChangeSetBuilder() + builder.start(IntentCode.TRANSFER_FULL) + + # Add all flags to the changeset + for key, flag_data in init_data.items(): + builder.add_put( + ObjectKind.FLAG, + key, + flag_data.get('version', 1), + flag_data + ) + + # Create selector for this version + selector = Selector.new_selector(str(version), version) + change_set = builder.finish(selector) + + basis = Basis( + change_set=change_set, + persist=False, + environment_id=None + ) + + return _Success(basis) + + except Exception as e: + return _Fail(f"Error fetching test data: {str(e)}") + + def sync(self) -> Generator[Update, None, None]: + """ + Implementation of the Synchronizer.sync method. + + Yields updates as test data changes occur. + """ + + + # First yield initial data + initial_result = self.fetch() + if isinstance(initial_result, _Fail): + yield Update( + state=DataSourceState.OFF, + error=DataSourceErrorInfo( + kind=DataSourceErrorKind.STORE_ERROR, + status_code=0, + time=current_time_millis(), + message=initial_result.error + ) + ) + return + + # Yield the initial successful state + yield Update( + state=DataSourceState.VALID, + change_set=initial_result.value.change_set + ) + + # Continue yielding updates as they arrive + while not self._closed: + try: + # Wait for updates with a timeout to allow checking closed status + try: + update = self._update_queue.get(timeout=1.0) + except Empty: + continue + + if update is None: # Sentinel value for shutdown + break + + yield update + + except Exception as e: + yield Update( + state=DataSourceState.OFF, + error=DataSourceErrorInfo( + kind=DataSourceErrorKind.UNKNOWN, + status_code=0, + time=current_time_millis(), + message=f"Error in test data synchronizer: {str(e)}" + ) + ) + break + + def close(self): + """Close the data source and clean up resources.""" + with self._lock: + if self._closed: + return + self._closed = True + + self._test_data._closed_instance(self) + # Signal shutdown to sync generator + self._update_queue.put(None) + + def upsert_flag(self, flag_data: dict): + """ + Called by TestDataV2 when a flag is updated. + + This method converts the flag update into an FDv2 changeset and + queues it for delivery through the sync() generator. + """ + with self._lock: + if self._closed: + return + + try: + version = self._test_data._get_version() + + # Build a changes transfer changeset + builder = ChangeSetBuilder() + builder.start(IntentCode.TRANSFER_CHANGES) + + # Add the updated flag + builder.add_put( + ObjectKind.FLAG, + flag_data['key'], + flag_data.get('version', 1), + flag_data + ) + + # Create selector for this version + selector = Selector.new_selector(str(version), version) + change_set = builder.finish(selector) + + # Queue the update + update = Update( + state=DataSourceState.VALID, + change_set=change_set + ) + + self._update_queue.put(update) + + except Exception as e: + # Queue an error update + error_update = Update( + state=DataSourceState.OFF, + error=DataSourceErrorInfo( + kind=DataSourceErrorKind.STORE_ERROR, + status_code=0, + time=current_time_millis(), + message=f"Error processing flag update: {str(e)}" + ) + ) + self._update_queue.put(error_update) diff --git a/ldclient/integrations/test_datav2.py b/ldclient/integrations/test_datav2.py new file mode 100644 index 00000000..8960fc7f --- /dev/null +++ b/ldclient/integrations/test_datav2.py @@ -0,0 +1,706 @@ +from __future__ import annotations + +import copy +from typing import Any, Dict, List, Optional, Set, Union + +from ldclient.context import Context +from ldclient.impl.integrations.test_datav2.test_data_sourcev2 import ( + _TestDataSourceV2 +) +from ldclient.impl.rwlock import ReadWriteLock + +TRUE_VARIATION_INDEX = 0 +FALSE_VARIATION_INDEX = 1 + + +def _variation_for_boolean(variation): + return TRUE_VARIATION_INDEX if variation else FALSE_VARIATION_INDEX + + +class FlagRuleBuilderV2: + """ + A builder for feature flag rules to be used with :class:`ldclient.integrations.test_datav2.FlagBuilderV2`. + + In the LaunchDarkly model, a flag can have any number of rules, and a rule can have any number of + clauses. A clause is an individual test such as \"name is 'X'\". A rule matches a user if all of the + rule's clauses match the user. + + To start defining a rule, use one of the flag builder's matching methods such as + :meth:`ldclient.integrations.test_datav2.FlagBuilderV2.if_match()`. + This defines the first clause for the rule. Optionally, you may add more + clauses with the rule builder's methods such as + :meth:`ldclient.integrations.test_datav2.FlagRuleBuilderV2.and_match()` or + :meth:`ldclient.integrations.test_datav2.FlagRuleBuilderV2.and_not_match()`. + Finally, call :meth:`ldclient.integrations.test_datav2.FlagRuleBuilderV2.then_return()` + to finish defining the rule. + """ + + def __init__(self, flag_builder: FlagBuilderV2): + self._flag_builder = flag_builder + self._clauses = [] # type: List[dict] + self._variation = None # type: Optional[int] + + def and_match(self, attribute: str, *values) -> FlagRuleBuilderV2: + """ + Adds another clause, using the \"is one of\" operator. + + This is a shortcut for calling :meth:`ldclient.integrations.test_datav2.FlagRuleBuilderV2.and_match_context()` + with \"user\" as the context kind. + + **Example:** create a rule that returns ``True`` if the name is \"Patsy\" and the country is \"gb\" + :: + + td.flag('flag') \\ + .if_match('name', 'Patsy') \\ + .and_match('country', 'gb') \\ + .then_return(True) + + :param attribute: the user attribute to match against + :param values: values to compare to + :return: the flag rule builder + """ + return self.and_match_context(Context.DEFAULT_KIND, attribute, *values) + + def and_match_context(self, context_kind: str, attribute: str, *values) -> FlagRuleBuilderV2: + """ + Adds another clause, using the \"is one of\" operator. This matching expression only + applies to contexts of a specific kind. + + **Example:** create a rule that returns ``True`` if the name attribute for the + \"company\" context is \"Ella\", and the country attribute for the \"company\" context is \"gb\": + :: + + td.flag('flag') \\ + .if_match_context('company', 'name', 'Ella') \\ + .and_match_context('company', 'country', 'gb') \\ + .then_return(True) + + :param context_kind: the context kind + :param attribute: the context attribute to match against + :param values: values to compare to + :return: the flag rule builder + """ + self._clauses.append({'contextKind': context_kind, 'attribute': attribute, 'op': 'in', 'values': list(values), 'negate': False}) + return self + + def and_not_match(self, attribute: str, *values) -> FlagRuleBuilderV2: + """ + Adds another clause, using the \"is not one of\" operator. + + This is a shortcut for calling :meth:`ldclient.integrations.test_datav2.FlagRuleBuilderV2.and_not_match_context()` + with \"user\" as the context kind. + + **Example:** create a rule that returns ``True`` if the name is \"Patsy\" and the country is not \"gb\" + :: + + td.flag('flag') \\ + .if_match('name', 'Patsy') \\ + .and_not_match('country', 'gb') \\ + .then_return(True) + + :param attribute: the user attribute to match against + :param values: values to compare to + :return: the flag rule builder + """ + return self.and_not_match_context(Context.DEFAULT_KIND, attribute, *values) + + def and_not_match_context(self, context_kind: str, attribute: str, *values) -> FlagRuleBuilderV2: + """ + Adds another clause, using the \"is not one of\" operator. This matching expression only + applies to contexts of a specific kind. + + **Example:** create a rule that returns ``True`` if the name attribute for the + \"company\" context is \"Ella\", and the country attribute for the \"company\" context is not \"gb\": + :: + + td.flag('flag') \\ + .if_match_context('company', 'name', 'Ella') \\ + .and_not_match_context('company', 'country', 'gb') \\ + .then_return(True) + + :param context_kind: the context kind + :param attribute: the context attribute to match against + :param values: values to compare to + :return: the flag rule builder + """ + self._clauses.append({'contextKind': context_kind, 'attribute': attribute, 'op': 'in', 'values': list(values), 'negate': True}) + return self + + def then_return(self, variation: Union[bool, int]) -> FlagBuilderV2: + """ + Finishes defining the rule, specifying the result as either a boolean + or a variation index. + + If the flag was previously configured with other variations and the variation specified is a boolean, + this also changes it to a boolean flag. + + :param bool|int variation: ``True`` or ``False`` or the desired variation index: + ``0`` for the first, ``1`` for the second, etc. + :return: the flag builder with this rule added + """ + if isinstance(variation, bool): + self._flag_builder.boolean_flag() + return self.then_return(_variation_for_boolean(variation)) + + self._variation = variation + self._flag_builder._add_rule(self) + return self._flag_builder + + # Note that _build is private by convention, because we don't want developers to + # consider it part of the public API, but it is still called from FlagBuilderV2. + def _build(self, id: str) -> dict: + """ + Creates a dictionary representation of the rule + + :param id: the rule id + :return: the dictionary representation of the rule + """ + return {'id': 'rule' + id, 'variation': self._variation, 'clauses': self._clauses} + + +class FlagBuilderV2: + """ + A builder for feature flag configurations to be used with :class:`ldclient.integrations.test_datav2.TestDataV2`. + + :see: :meth:`ldclient.integrations.test_datav2.TestDataV2.flag()` + :see: :meth:`ldclient.integrations.test_datav2.TestDataV2.update()` + """ + + def __init__(self, key: str): + """:param str key: The name of the flag""" + self._key = key + self._on = True + self._variations = [] # type: List[Any] + self._off_variation = None # type: Optional[int] + self._fallthrough_variation = None # type: Optional[int] + self._targets = {} # type: Dict[str, Dict[int, Set[str]]] + self._rules = [] # type: List[FlagRuleBuilderV2] + + # Note that _copy is private by convention, because we don't want developers to + # consider it part of the public API, but it is still called from TestDataV2. + def _copy(self) -> FlagBuilderV2: + """ + Creates a deep copy of the flag builder. Subsequent updates to the + original ``FlagBuilderV2`` object will not update the copy and vise versa. + + :return: a copy of the flag builder object + """ + to = FlagBuilderV2(self._key) + + to._on = self._on + to._variations = copy.copy(self._variations) + to._off_variation = self._off_variation + to._fallthrough_variation = self._fallthrough_variation + to._targets = dict() + for k, v in self._targets.items(): + to._targets[k] = copy.copy(v) + to._rules = copy.copy(self._rules) + + return to + + def on(self, on: bool) -> FlagBuilderV2: + """ + Sets targeting to be on or off for this flag. + + The effect of this depends on the rest of the flag configuration, just as it does on the + real LaunchDarkly dashboard. In the default configuration that you get from calling + :meth:`ldclient.integrations.test_datav2.TestDataV2.flag()` with a new flag key, + the flag will return ``False`` whenever targeting is off, and ``True`` when + targeting is on. + + :param on: ``True`` if targeting should be on + :return: the flag builder + """ + self._on = on + return self + + def fallthrough_variation(self, variation: Union[bool, int]) -> FlagBuilderV2: + """ + Specifies the fallthrough variation. The fallthrough is the value + that is returned if targeting is on and the user was not matched by a more specific + target or rule. + + If the flag was previously configured with other variations and the variation + specified is a boolean, this also changes it to a boolean flag. + + :param bool|int variation: ``True`` or ``False`` or the desired fallthrough variation index: + ``0`` for the first, ``1`` for the second, etc. + :return: the flag builder + """ + if isinstance(variation, bool): + self.boolean_flag()._fallthrough_variation = _variation_for_boolean(variation) + return self + + self._fallthrough_variation = variation + return self + + def off_variation(self, variation: Union[bool, int]) -> FlagBuilderV2: + """ + Specifies the fallthrough variation. This is the variation that is returned + whenever targeting is off. + + If the flag was previously configured with other variations and the variation + specified is a boolean, this also changes it to a boolean flag. + + :param bool|int variation: ``True`` or ``False`` or the desired off variation index: + ``0`` for the first, ``1`` for the second, etc. + :return: the flag builder + """ + if isinstance(variation, bool): + self.boolean_flag()._off_variation = _variation_for_boolean(variation) + return self + + self._off_variation = variation + return self + + def boolean_flag(self) -> FlagBuilderV2: + """ + A shortcut for setting the flag to use the standard boolean configuration. + + This is the default for all new flags created with + :meth:`ldclient.integrations.test_datav2.TestDataV2.flag()`. + + The flag will have two variations, ``True`` and ``False`` (in that order); + it will return ``False`` whenever targeting is off, and ``True`` when targeting is on + if no other settings specify otherwise. + + :return: the flag builder + """ + if self._is_boolean_flag(): + return self + + return self.variations(True, False).fallthrough_variation(TRUE_VARIATION_INDEX).off_variation(FALSE_VARIATION_INDEX) + + def _is_boolean_flag(self): + return len(self._variations) == 2 and self._variations[TRUE_VARIATION_INDEX] is True and self._variations[FALSE_VARIATION_INDEX] is False + + def variations(self, *variations) -> FlagBuilderV2: + """ + Changes the allowable variation values for the flag. + + The value may be of any valid JSON type. For instance, a boolean flag + normally has ``True, False``; a string-valued flag might have + ``'red', 'green'``; etc. + + **Example:** A single variation + :: + + td.flag('new-flag').variations(True) + + **Example:** Multiple variations + :: + + td.flag('new-flag').variations('red', 'green', 'blue') + + :param variations: the desired variations + :return: the flag builder + """ + self._variations = list(variations) + + return self + + def variation_for_all(self, variation: Union[bool, int]) -> FlagBuilderV2: + """ + Sets the flag to always return the specified variation for all contexts. + + The variation is specified, targeting is switched on, and any existing targets or rules are removed. + The fallthrough variation is set to the specified value. The off variation is left unchanged. + + If the flag was previously configured with other variations and the variation specified is a boolean, + this also changes it to a boolean flag. + + :param bool|int variation: ``True`` or ``False`` or the desired variation index to return: + ``0`` for the first, ``1`` for the second, etc. + :return: the flag builder + """ + if isinstance(variation, bool): + return self.boolean_flag().variation_for_all(_variation_for_boolean(variation)) + + return self.clear_rules().clear_targets().on(True).fallthrough_variation(variation) + + def value_for_all(self, value: Any) -> FlagBuilderV2: + """ + Sets the flag to always return the specified variation value for all users. + + The value may be of any JSON type. This method changes the flag to have only + a single variation, which is this value, and to return the same variation + regardless of whether targeting is on or off. Any existing targets or rules + are removed. + + :param value the desired value to be returned for all users + :return the flag builder + """ + return self.variations(value).variation_for_all(0) + + def variation_for_user(self, user_key: str, variation: Union[bool, int]) -> FlagBuilderV2: + """ + Sets the flag to return the specified variation for a specific user key when targeting + is on. + + This has no effect when targeting is turned off for the flag. + + If the flag was previously configured with other variations and the variation specified is a boolean, + this also changes it to a boolean flag. + + :param user_key: a user key + :param bool|int variation: ``True`` or ``False`` or the desired variation index to return: + ``0`` for the first, ``1`` for the second, etc. + :return: the flag builder + """ + return self.variation_for_key(Context.DEFAULT_KIND, user_key, variation) + + def variation_for_key(self, context_kind: str, context_key: str, variation: Union[bool, int]) -> FlagBuilderV2: + """ + Sets the flag to return the specified variation for a specific context, identified + by context kind and key, when targeting is on. + + This has no effect when targeting is turned off for the flag. + + If the flag was previously configured with other variations and the variation specified is a boolean, + this also changes it to a boolean flag. + + :param context_kind: the context kind + :param context_key: the context key + :param bool|int variation: ``True`` or ``False`` or the desired variation index to return: + ``0`` for the first, ``1`` for the second, etc. + :return: the flag builder + """ + if isinstance(variation, bool): + # `variation` is True/False value + return self.boolean_flag().variation_for_key(context_kind, context_key, _variation_for_boolean(variation)) + + # `variation` specifies the index of the variation to set + targets = self._targets.get(context_kind) + if targets is None: + targets = {} + self._targets[context_kind] = targets + + for idx, var in enumerate(self._variations): + if idx == variation: + # If there is no set at the current variation, set it to be empty + target_for_variation = targets.get(idx) + if target_for_variation is None: + target_for_variation = set() + targets[idx] = target_for_variation + + # If key is not in the current variation set, add it + target_for_variation.add(context_key) + + else: + # Remove key from the other variation set if necessary + if idx in targets: + targets[idx].discard(context_key) + + return self + + def _add_rule(self, flag_rule_builder: FlagRuleBuilderV2): + self._rules.append(flag_rule_builder) + + def if_match(self, attribute: str, *values) -> FlagRuleBuilderV2: + """ + Starts defining a flag rule, using the \"is one of\" operator. + + This is a shortcut for calling :meth:`ldclient.integrations.test_datav2.FlagBuilderV2.if_match_context()` + with \"user\" as the context kind. + + **Example:** create a rule that returns ``True`` if the name is \"Patsy\" or \"Edina\" + :: + + td.flag(\"flag\") \\ + .if_match('name', 'Patsy', 'Edina') \\ + .then_return(True) + + :param attribute: the user attribute to match against + :param values: values to compare to + :return: the flag rule builder + """ + return self.if_match_context(Context.DEFAULT_KIND, attribute, *values) + + def if_match_context(self, context_kind: str, attribute: str, *values) -> FlagRuleBuilderV2: + """ + Starts defining a flag rule, using the \"is one of\" operator. This matching expression only + applies to contexts of a specific kind. + + **Example:** create a rule that returns ``True`` if the name attribute for the + company\" context is \"Ella\" or \"Monsoon\": + :: + + td.flag(\"flag\") \\ + .if_match_context('company', 'name', 'Ella', 'Monsoon') \\ + .then_return(True) + + :param context_kind: the context kind + :param attribute: the context attribute to match against + :param values: values to compare to + :return: the flag rule builder + """ + flag_rule_builder = FlagRuleBuilderV2(self) + return flag_rule_builder.and_match_context(context_kind, attribute, *values) + + def if_not_match(self, attribute: str, *values) -> FlagRuleBuilderV2: + """ + Starts defining a flag rule, using the \"is not one of\" operator. + + This is a shortcut for calling :meth:`ldclient.integrations.test_datav2.FlagBuilderV2.if_not_match_context()` + with \"user\" as the context kind. + + **Example:** create a rule that returns ``True`` if the name is neither \"Saffron\" nor \"Bubble\" + :: + + td.flag(\"flag\") \\ + .if_not_match('name', 'Saffron', 'Bubble') \\ + .then_return(True) + + :param attribute: the user attribute to match against + :param values: values to compare to + :return: the flag rule builder + """ + return self.if_not_match_context(Context.DEFAULT_KIND, attribute, *values) + + def if_not_match_context(self, context_kind: str, attribute: str, *values) -> FlagRuleBuilderV2: + """ + Starts defining a flag rule, using the \"is not one of\" operator. This matching expression only + applies to contexts of a specific kind. + + **Example:** create a rule that returns ``True`` if the name attribute for the + \"company\" context is neither \"Pendant\" nor \"Sterling Cooper\": + :: + + td.flag(\"flag\") \\ + .if_not_match('company', 'name', 'Pendant', 'Sterling Cooper') \\ + .then_return(True) + + :param context_kind: the context kind + :param attribute: the context attribute to match against + :param values: values to compare to + :return: the flag rule builder + """ + flag_rule_builder = FlagRuleBuilderV2(self) + return flag_rule_builder.and_not_match_context(context_kind, attribute, *values) + + def clear_rules(self) -> FlagBuilderV2: + """ + Removes any existing rules from the flag. + This undoes the effect of methods like + :meth:`ldclient.integrations.test_datav2.FlagBuilderV2.if_match()`. + + :return: the same flag builder + """ + self._rules = [] + return self + + def clear_targets(self) -> FlagBuilderV2: + """ + Removes any existing targets from the flag. + This undoes the effect of methods like + :meth:`ldclient.integrations.test_datav2.FlagBuilderV2.variation_for_user()`. + + :return: the same flag builder + """ + self._targets = {} + return self + + # Note that _build is private by convention, because we don't want developers to + # consider it part of the public API, but it is still called from TestDataV2. + def _build(self, version: int) -> dict: + """ + Creates a dictionary representation of the flag + + :param version: the version number of the rule + :return: the dictionary representation of the flag + """ + base_flag_object = {'key': self._key, 'version': version, 'on': self._on, 'variations': self._variations, 'prerequisites': [], 'salt': ''} + + base_flag_object['offVariation'] = self._off_variation + base_flag_object['fallthrough'] = {'variation': self._fallthrough_variation} + + targets = [] + context_targets = [] + for target_context_kind, target_variations in self._targets.items(): + for var_index, target_keys in target_variations.items(): + if target_context_kind == Context.DEFAULT_KIND: + targets.append({'variation': var_index, 'values': sorted(list(target_keys))}) # sorting just for test determinacy + context_targets.append({'contextKind': target_context_kind, 'variation': var_index, 'values': []}) + else: + context_targets.append({'contextKind': target_context_kind, 'variation': var_index, 'values': sorted(list(target_keys))}) # sorting just for test determinacy + base_flag_object['targets'] = targets + base_flag_object['contextTargets'] = context_targets + + rules = [] + for idx, rule in enumerate(self._rules): + rules.append(rule._build(str(idx))) + base_flag_object['rules'] = rules + + return base_flag_object + + +class TestDataV2: + """ + A mechanism for providing dynamically updatable feature flag state in a + simplified form to an SDK client in test scenarios using the FDv2 protocol. + + This type is not stable, and not subject to any backwards + compatibility guarantees or semantic versioning. It is not suitable for production usage. + + Do not use it. + You have been warned. + + Unlike ``Files``, this mechanism does not use any external resources. It provides only + the data that the application has put into it using the ``update`` method. + :: + + from ldclient.impl.datasystem import config as datasystem_config + + td = TestDataV2.data_source() + td.update(td.flag('flag-key-1').variation_for_all(True)) + + # Configure the data system with TestDataV2 as both initializer and synchronizer + data_config = datasystem_config.custom() + data_config.initializers([lambda: td.build_initializer()]) + data_config.synchronizers(lambda: td.build_synchronizer()) + + # TODO(fdv2): This will be integrated with the main Config in a future version + # For now, TestDataV2 is primarily intended for unit testing scenarios + + # flags can be updated at any time: + td.update(td.flag('flag-key-1'). + variation_for_user('some-user-key', True). + fallthrough_variation(False)) + + The above example uses a simple boolean flag, but more complex configurations are possible using + the methods of the ``FlagBuilderV2`` that is returned by ``flag``. ``FlagBuilderV2`` + supports many of the ways a flag can be configured on the LaunchDarkly dashboard, but does not + currently support 1. rule operators other than "in" and "not in", or 2. percentage rollouts. + + If the same ``TestDataV2`` instance is used to configure multiple ``LDClient`` instances, + any changes made to the data will propagate to all of the ``LDClient`` instances. + """ + + # Prevent pytest from treating this as a test class + __test__ = False + + def __init__(self): + self._flag_builders = {} + self._current_flags = {} + self._lock = ReadWriteLock() + self._instances = [] + self._version = 0 + + @staticmethod + def data_source() -> TestDataV2: + """ + Creates a new instance of the test data source. + + :return: a new configurable test data source + """ + return TestDataV2() + + def flag(self, key: str) -> FlagBuilderV2: + """ + Creates or copies a ``FlagBuilderV2`` for building a test flag configuration. + + If this flag key has already been defined in this ``TestDataV2`` instance, then the builder + starts with the same configuration that was last provided for this flag. + + Otherwise, it starts with a new default configuration in which the flag has ``True`` and + ``False`` variations, is ``True`` for all users when targeting is turned on and + ``False`` otherwise, and currently has targeting turned on. You can change any of those + properties, and provide more complex behavior, using the ``FlagBuilderV2`` methods. + + Once you have set the desired configuration, pass the builder to ``update``. + + :param str key: the flag key + :return: the flag configuration builder object + """ + try: + self._lock.rlock() + if key in self._flag_builders and self._flag_builders[key]: + return self._flag_builders[key]._copy() + + return FlagBuilderV2(key).boolean_flag() + finally: + self._lock.runlock() + + def update(self, flag_builder: FlagBuilderV2) -> TestDataV2: + """ + Updates the test data with the specified flag configuration. + + This has the same effect as if a flag were added or modified on the LaunchDarkly dashboard. + It immediately propagates the flag change to any ``LDClient`` instance(s) that you have + already configured to use this ``TestDataV2``. If no ``LDClient`` has been started yet, + it simply adds this flag to the test data which will be provided to any ``LDClient`` that + you subsequently configure. + + Any subsequent changes to this ``FlagBuilderV2`` instance do not affect the test data, + unless you call ``update`` again. + + :param flag_builder: a flag configuration builder + :return: self (the TestDataV2 object) + """ + try: + self._lock.lock() + + old_version = 0 + if flag_builder._key in self._current_flags: + old_flag = self._current_flags[flag_builder._key] + if old_flag: + old_version = old_flag['version'] + + new_flag = flag_builder._build(old_version + 1) + + self._current_flags[flag_builder._key] = new_flag + self._flag_builders[flag_builder._key] = flag_builder._copy() + finally: + self._lock.unlock() + + for instance in self._instances: + instance.upsert_flag(new_flag) + + return self + + def _make_init_data(self) -> Dict[str, Any]: + try: + self._lock.rlock() + return copy.copy(self._current_flags) + finally: + self._lock.runlock() + + def _get_version(self) -> int: + try: + self._lock.lock() + version = self._version + self._version += 1 + return version + finally: + self._lock.unlock() + + def _closed_instance(self, instance): + try: + self._lock.lock() + if instance in self._instances: + self._instances.remove(instance) + finally: + self._lock.unlock() + + def _add_instance(self, instance): + try: + self._lock.lock() + self._instances.append(instance) + finally: + self._lock.unlock() + + def build_initializer(self) -> _TestDataSourceV2: + """ + Creates an initializer that can be used with the FDv2 data system. + + :return: a test data initializer + """ + return _TestDataSourceV2(self) + + def build_synchronizer(self) -> _TestDataSourceV2: + """ + Creates a synchronizer that can be used with the FDv2 data system. + + :return: a test data synchronizer + """ + return _TestDataSourceV2(self) \ No newline at end of file diff --git a/ldclient/testing/integrations/test_test_data_sourcev2.py b/ldclient/testing/integrations/test_test_data_sourcev2.py new file mode 100644 index 00000000..fc0936f2 --- /dev/null +++ b/ldclient/testing/integrations/test_test_data_sourcev2.py @@ -0,0 +1,449 @@ +import threading +import time +from typing import Callable + +import pytest + +from ldclient.context import Context +from ldclient.impl.datasystem.protocolv2 import ( + ChangeType, + IntentCode, + ObjectKind +) +from ldclient.impl.util import _Fail, _Success +from ldclient.integrations.test_datav2 import FlagBuilderV2, TestDataV2 +from ldclient.interfaces import DataSourceState + +# Test Data + Data Source V2 + + +def test_creates_valid_initializer(): + """Test that TestDataV2 creates a working initializer""" + td = TestDataV2.data_source() + initializer = td.build_initializer() + + result = initializer.fetch() + assert isinstance(result, _Success) + + basis = result.value + assert not basis.persist + assert basis.environment_id is None + assert basis.change_set.intent_code == IntentCode.TRANSFER_FULL + assert len(basis.change_set.changes) == 0 # No flags added yet + + +def test_creates_valid_synchronizer(): + """Test that TestDataV2 creates a working synchronizer""" + td = TestDataV2.data_source() + synchronizer = td.build_synchronizer() + + updates = [] + update_count = 0 + + def collect_updates(): + nonlocal update_count + for update in synchronizer.sync(): + updates.append(update) + update_count += 1 + + if update_count == 1: + # Should get initial state + assert update.state == DataSourceState.VALID + assert update.change_set is not None + assert update.change_set.intent_code == IntentCode.TRANSFER_FULL + synchronizer.close() + break + + # Start the synchronizer in a thread with timeout to prevent hanging + sync_thread = threading.Thread(target=collect_updates) + sync_thread.start() + + # Wait for the thread to complete with timeout + sync_thread.join(timeout=5) + + # Ensure thread completed successfully + if sync_thread.is_alive(): + synchronizer.close() + sync_thread.join() + pytest.fail("Synchronizer test timed out after 5 seconds") + + assert len(updates) == 1 + + +def verify_flag_builder_v2(desc: str, expected_props: dict, builder_actions: Callable[[FlagBuilderV2], FlagBuilderV2]): + """Helper function to verify flag builder behavior""" + all_expected_props = { + 'key': 'test-flag', + 'version': 1, + 'on': True, + 'prerequisites': [], + 'targets': [], + 'contextTargets': [], + 'rules': [], + 'salt': '', + 'variations': [True, False], + 'offVariation': 1, + 'fallthrough': {'variation': 0}, + } + all_expected_props.update(expected_props) + + td = TestDataV2.data_source() + flag_builder = builder_actions(td.flag(key='test-flag')) + built_flag = flag_builder._build(1) + assert built_flag == all_expected_props, f"did not get expected flag properties for '{desc}' test" + + +@pytest.mark.parametrize( + 'expected_props,builder_actions', + [ + pytest.param({}, lambda f: f, id='defaults'), + pytest.param({}, lambda f: f.boolean_flag(), id='changing default flag to boolean flag has no effect'), + pytest.param( + {}, + lambda f: f.variations('a', 'b').boolean_flag(), + id='non-boolean flag can be changed to boolean flag', + ), + pytest.param({'on': False}, lambda f: f.on(False), id='flag can be turned off'), + pytest.param( + {}, + lambda f: f.on(False).on(True), + id='flag can be turned on', + ), + pytest.param({'fallthrough': {'variation': 1}}, lambda f: f.variation_for_all(False), id='set false variation for all'), + pytest.param({'fallthrough': {'variation': 0}}, lambda f: f.variation_for_all(True), id='set true variation for all'), + pytest.param({'variations': ['a', 'b', 'c'], 'fallthrough': {'variation': 2}}, lambda f: f.variations('a', 'b', 'c').variation_for_all(2), id='set variation index for all'), + pytest.param({'offVariation': 0}, lambda f: f.off_variation(True), id='set off variation boolean'), + pytest.param({'variations': ['a', 'b', 'c'], 'offVariation': 2}, lambda f: f.variations('a', 'b', 'c').off_variation(2), id='set off variation index'), + pytest.param( + { + 'targets': [ + {'variation': 0, 'values': ['key1', 'key2']}, + ], + 'contextTargets': [ + {'contextKind': 'user', 'variation': 0, 'values': []}, + {'contextKind': 'kind1', 'variation': 0, 'values': ['key3', 'key4']}, + {'contextKind': 'kind1', 'variation': 1, 'values': ['key5', 'key6']}, + ], + }, + lambda f: f.variation_for_key('user', 'key1', True) + .variation_for_key('user', 'key2', True) + .variation_for_key('kind1', 'key3', True) + .variation_for_key('kind1', 'key5', False) + .variation_for_key('kind1', 'key4', True) + .variation_for_key('kind1', 'key6', False), + id='set context targets as boolean', + ), + pytest.param( + { + 'variations': ['a', 'b'], + 'targets': [ + {'variation': 0, 'values': ['key1', 'key2']}, + ], + 'contextTargets': [ + {'contextKind': 'user', 'variation': 0, 'values': []}, + {'contextKind': 'kind1', 'variation': 0, 'values': ['key3', 'key4']}, + {'contextKind': 'kind1', 'variation': 1, 'values': ['key5', 'key6']}, + ], + }, + lambda f: f.variations('a', 'b') + .variation_for_key('user', 'key1', 0) + .variation_for_key('user', 'key2', 0) + .variation_for_key('kind1', 'key3', 0) + .variation_for_key('kind1', 'key5', 1) + .variation_for_key('kind1', 'key4', 0) + .variation_for_key('kind1', 'key6', 1), + id='set context targets as variation index', + ), + pytest.param( + {'contextTargets': [{'contextKind': 'kind1', 'variation': 0, 'values': ['key1', 'key2']}, {'contextKind': 'kind1', 'variation': 1, 'values': ['key3']}]}, + lambda f: f.variation_for_key('kind1', 'key1', 0).variation_for_key('kind1', 'key2', 1).variation_for_key('kind1', 'key3', 1).variation_for_key('kind1', 'key2', 0), + id='replace existing context target key', + ), + pytest.param( + { + 'variations': ['a', 'b'], + 'contextTargets': [ + {'contextKind': 'kind1', 'variation': 1, 'values': ['key1']}, + ], + }, + lambda f: f.variations('a', 'b').variation_for_key('kind1', 'key1', 1).variation_for_key('kind1', 'key2', 3), + id='ignore target for nonexistent variation', + ), + pytest.param( + {'targets': [{'variation': 0, 'values': ['key1']}], 'contextTargets': [{'contextKind': 'user', 'variation': 0, 'values': []}]}, + lambda f: f.variation_for_user('key1', True), + id='variation_for_user is shortcut for variation_for_key', + ), + pytest.param({}, lambda f: f.variation_for_key('kind1', 'key1', 0).clear_targets(), id='clear targets'), + pytest.param( + {'rules': [{'variation': 1, 'id': 'rule0', 'clauses': [{'contextKind': 'kind1', 'attribute': 'attr1', 'op': 'in', 'values': ['a', 'b'], 'negate': False}]}]}, + lambda f: f.if_match_context('kind1', 'attr1', 'a', 'b').then_return(1), + id='if_match_context', + ), + pytest.param( + {'rules': [{'variation': 1, 'id': 'rule0', 'clauses': [{'contextKind': 'kind1', 'attribute': 'attr1', 'op': 'in', 'values': ['a', 'b'], 'negate': True}]}]}, + lambda f: f.if_not_match_context('kind1', 'attr1', 'a', 'b').then_return(1), + id='if_not_match_context', + ), + pytest.param( + {'rules': [{'variation': 1, 'id': 'rule0', 'clauses': [{'contextKind': 'user', 'attribute': 'attr1', 'op': 'in', 'values': ['a', 'b'], 'negate': False}]}]}, + lambda f: f.if_match('attr1', 'a', 'b').then_return(1), + id='if_match is shortcut for if_match_context', + ), + pytest.param( + {'rules': [{'variation': 1, 'id': 'rule0', 'clauses': [{'contextKind': 'user', 'attribute': 'attr1', 'op': 'in', 'values': ['a', 'b'], 'negate': True}]}]}, + lambda f: f.if_not_match('attr1', 'a', 'b').then_return(1), + id='if_not_match is shortcut for if_not_match_context', + ), + pytest.param( + { + 'rules': [ + { + 'variation': 1, + 'id': 'rule0', + 'clauses': [ + {'contextKind': 'kind1', 'attribute': 'attr1', 'op': 'in', 'values': ['a', 'b'], 'negate': False}, + {'contextKind': 'kind1', 'attribute': 'attr2', 'op': 'in', 'values': ['c', 'd'], 'negate': False}, + ], + } + ] + }, + lambda f: f.if_match_context('kind1', 'attr1', 'a', 'b').and_match_context('kind1', 'attr2', 'c', 'd').then_return(1), + id='and_match_context', + ), + pytest.param( + { + 'rules': [ + { + 'variation': 1, + 'id': 'rule0', + 'clauses': [ + {'contextKind': 'kind1', 'attribute': 'attr1', 'op': 'in', 'values': ['a', 'b'], 'negate': False}, + {'contextKind': 'kind1', 'attribute': 'attr2', 'op': 'in', 'values': ['c', 'd'], 'negate': True}, + ], + } + ] + }, + lambda f: f.if_match_context('kind1', 'attr1', 'a', 'b').and_not_match_context('kind1', 'attr2', 'c', 'd').then_return(1), + id='and_not_match_context', + ), + pytest.param({}, lambda f: f.if_match_context('kind1', 'attr1', 'a').then_return(1).clear_rules(), id='clear rules'), + ], +) +def test_flag_configs_parameterized_v2(expected_props: dict, builder_actions: Callable[[FlagBuilderV2], FlagBuilderV2]): + verify_flag_builder_v2('x', expected_props, builder_actions) + + +def test_initializer_fetches_flag_data(): + """Test that initializer returns flag data correctly""" + td = TestDataV2.data_source() + td.update(td.flag('some-flag').variation_for_all(True)) + + initializer = td.build_initializer() + result = initializer.fetch() + + assert isinstance(result, _Success) + basis = result.value + assert len(basis.change_set.changes) == 1 + + change = basis.change_set.changes[0] + assert change.action == ChangeType.PUT + assert change.kind == ObjectKind.FLAG + assert change.key == 'some-flag' + assert change.object['key'] == 'some-flag' + assert change.object['on'] is True + + +def test_synchronizer_yields_initial_data(): + """Test that synchronizer yields initial data correctly""" + td = TestDataV2.data_source() + td.update(td.flag('initial-flag').variation_for_all(False)) + + synchronizer = td.build_synchronizer() + + update_iter = iter(synchronizer.sync()) + initial_update = next(update_iter) + + assert initial_update.state == DataSourceState.VALID + assert initial_update.change_set is not None + assert initial_update.change_set.intent_code == IntentCode.TRANSFER_FULL + assert len(initial_update.change_set.changes) == 1 + + change = initial_update.change_set.changes[0] + assert change.key == 'initial-flag' + + synchronizer.close() + + +def test_synchronizer_receives_updates(): + """Test that synchronizer receives flag updates""" + td = TestDataV2.data_source() + synchronizer = td.build_synchronizer() + + updates = [] + update_count = 0 + + def collect_updates(): + nonlocal update_count + for update in synchronizer.sync(): + updates.append(update) + update_count += 1 + + if update_count >= 2: + synchronizer.close() + break + + # Start the synchronizer in a thread + sync_thread = threading.Thread(target=collect_updates) + sync_thread.start() + + # Wait a bit for initial update + time.sleep(0.1) + + # Update a flag + td.update(td.flag('updated-flag').variation_for_all(True)) + + # Wait for the thread to complete + sync_thread.join(timeout=5) + + assert len(updates) >= 2 + + # First update should be initial (empty) + assert updates[0].state == DataSourceState.VALID + assert updates[0].change_set.intent_code == IntentCode.TRANSFER_FULL + + # Second update should be the flag change + assert updates[1].state == DataSourceState.VALID + assert updates[1].change_set.intent_code == IntentCode.TRANSFER_CHANGES + assert len(updates[1].change_set.changes) == 1 + assert updates[1].change_set.changes[0].key == 'updated-flag' + + +def test_multiple_synchronizers_receive_updates(): + """Test that multiple synchronizers receive the same updates""" + td = TestDataV2.data_source() + sync1 = td.build_synchronizer() + sync2 = td.build_synchronizer() + + updates1 = [] + updates2 = [] + + def collect_updates_1(): + for update in sync1.sync(): + updates1.append(update) + if len(updates1) >= 2: + sync1.close() + break + + def collect_updates_2(): + for update in sync2.sync(): + updates2.append(update) + if len(updates2) >= 2: + sync2.close() + break + + # Start both synchronizers + thread1 = threading.Thread(target=collect_updates_1) + thread2 = threading.Thread(target=collect_updates_2) + + thread1.start() + thread2.start() + + time.sleep(0.1) # Let them get initial state + + # Update a flag + td.update(td.flag('shared-flag').variation_for_all(True)) + + thread1.join(timeout=5) + thread2.join(timeout=5) + + assert len(updates1) >= 2 + assert len(updates2) >= 2 + + # Both should receive the same updates + assert updates1[1].change_set.changes[0].key == 'shared-flag' + assert updates2[1].change_set.changes[0].key == 'shared-flag' + + +def test_closed_synchronizer_stops_yielding(): + """Test that closed synchronizer stops yielding updates""" + td = TestDataV2.data_source() + synchronizer = td.build_synchronizer() + + updates = [] + + # Get initial update then close + for update in synchronizer.sync(): + updates.append(update) + synchronizer.close() + break + + assert len(updates) == 1 + + # Further updates should not be received + td.update(td.flag('post-close-flag').variation_for_all(True)) + + # Try to get more updates - should get an error state indicating closure + additional_updates = [] + for update in synchronizer.sync(): + additional_updates.append(update) + break + + # Should get exactly one error update indicating the synchronizer is closed + assert len(additional_updates) == 1 + assert additional_updates[0].state == DataSourceState.OFF + assert "TestDataV2 source has been closed" in additional_updates[0].error.message + + +def test_initializer_can_sync(): + """Test that an initializer can call sync() and get initial data""" + td = TestDataV2.data_source() + td.update(td.flag('test-flag').variation_for_all(True)) + + initializer = td.build_initializer() + sync_gen = initializer.sync() + + # Should get initial update with data + initial_update = next(sync_gen) + assert initial_update.state == DataSourceState.VALID + assert initial_update.change_set.intent_code == IntentCode.TRANSFER_FULL + assert len(initial_update.change_set.changes) == 1 + assert initial_update.change_set.changes[0].key == 'test-flag' + + +def test_value_for_all(): + """Test value_for_all method creates single-variation flag""" + td = TestDataV2.data_source() + flag = td.flag('value-flag').value_for_all('custom-value') + built_flag = flag._build(1) + + assert built_flag['variations'] == ['custom-value'] + assert built_flag['fallthrough']['variation'] == 0 + + +def test_version_increment(): + """Test that versions increment correctly""" + td = TestDataV2.data_source() + + flag1 = td.flag('flag1').variation_for_all(True) + td.update(flag1) + + flag2 = td.flag('flag1').variation_for_all(False) + td.update(flag2) + + # Get the final flag data + data = td._make_init_data() + assert data['flag1']['version'] == 2 # Should have incremented + + +def test_error_handling_in_fetch(): + """Test error handling in the fetch method""" + td = TestDataV2.data_source() + initializer = td.build_initializer() + + # Close the initializer to trigger error condition + initializer.close() + + result = initializer.fetch() + assert isinstance(result, _Fail) + assert "TestDataV2 source has been closed" in result.error From 7cee266b1f973df3d5f197e7f63697e72f64ff72 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 2 Sep 2025 12:27:22 -0400 Subject: [PATCH 2/4] Fixing formatting issues --- .../test_datav2/test_data_sourcev2.py | 1 - ldclient/integrations/test_datav2.py | 16 ++++++++-------- .../integrations/test_test_data_sourcev2.py | 5 ++--- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py index c644a626..3c876f41 100644 --- a/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py +++ b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py @@ -1,5 +1,4 @@ import threading -import time from queue import Empty, Queue from typing import Generator diff --git a/ldclient/integrations/test_datav2.py b/ldclient/integrations/test_datav2.py index 8960fc7f..76de7957 100644 --- a/ldclient/integrations/test_datav2.py +++ b/ldclient/integrations/test_datav2.py @@ -37,8 +37,8 @@ class FlagRuleBuilderV2: def __init__(self, flag_builder: FlagBuilderV2): self._flag_builder = flag_builder - self._clauses = [] # type: List[dict] - self._variation = None # type: Optional[int] + self._clauses: List[dict] = [] + self._variation: Optional[int] = None def and_match(self, attribute: str, *values) -> FlagRuleBuilderV2: """ @@ -170,11 +170,11 @@ def __init__(self, key: str): """:param str key: The name of the flag""" self._key = key self._on = True - self._variations = [] # type: List[Any] - self._off_variation = None # type: Optional[int] - self._fallthrough_variation = None # type: Optional[int] - self._targets = {} # type: Dict[str, Dict[int, Set[str]]] - self._rules = [] # type: List[FlagRuleBuilderV2] + self._variations: List[Any] = [] + self._off_variation: Optional[int] = None + self._fallthrough_variation: Optional[int] = None + self._targets: Dict[str, Dict[int, Set[str]]] = {} + self._rules: List[FlagRuleBuilderV2] = [] # Note that _copy is private by convention, because we don't want developers to # consider it part of the public API, but it is still called from TestDataV2. @@ -703,4 +703,4 @@ def build_synchronizer(self) -> _TestDataSourceV2: :return: a test data synchronizer """ - return _TestDataSourceV2(self) \ No newline at end of file + return _TestDataSourceV2(self) diff --git a/ldclient/testing/integrations/test_test_data_sourcev2.py b/ldclient/testing/integrations/test_test_data_sourcev2.py index fc0936f2..ac52278a 100644 --- a/ldclient/testing/integrations/test_test_data_sourcev2.py +++ b/ldclient/testing/integrations/test_test_data_sourcev2.py @@ -4,7 +4,6 @@ import pytest -from ldclient.context import Context from ldclient.impl.datasystem.protocolv2 import ( ChangeType, IntentCode, @@ -399,10 +398,10 @@ def test_initializer_can_sync(): """Test that an initializer can call sync() and get initial data""" td = TestDataV2.data_source() td.update(td.flag('test-flag').variation_for_all(True)) - + initializer = td.build_initializer() sync_gen = initializer.sync() - + # Should get initial update with data initial_update = next(sync_gen) assert initial_update.state == DataSourceState.VALID From a9e8ee2110166aa04b6fff7dd1bed9d5d9a3b230 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 2 Sep 2025 12:49:01 -0400 Subject: [PATCH 3/4] fix lint --- ldclient/impl/integrations/test_datav2/__init__.py | 1 - ldclient/impl/integrations/test_datav2/test_data_sourcev2.py | 1 - 2 files changed, 2 deletions(-) diff --git a/ldclient/impl/integrations/test_datav2/__init__.py b/ldclient/impl/integrations/test_datav2/__init__.py index 8b137891..e69de29b 100644 --- a/ldclient/impl/integrations/test_datav2/__init__.py +++ b/ldclient/impl/integrations/test_datav2/__init__.py @@ -1 +0,0 @@ - diff --git a/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py index 3c876f41..12f68c92 100644 --- a/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py +++ b/ldclient/impl/integrations/test_datav2/test_data_sourcev2.py @@ -92,7 +92,6 @@ def sync(self) -> Generator[Update, None, None]: Yields updates as test data changes occur. """ - # First yield initial data initial_result = self.fetch() if isinstance(initial_result, _Fail): From 5652bd32bf44c13685719fe42da67ced28dffe0b Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 4 Sep 2025 14:03:26 -0400 Subject: [PATCH 4/4] fix lock issue --- ldclient/integrations/test_datav2.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ldclient/integrations/test_datav2.py b/ldclient/integrations/test_datav2.py index 76de7957..84ccf30d 100644 --- a/ldclient/integrations/test_datav2.py +++ b/ldclient/integrations/test_datav2.py @@ -637,6 +637,7 @@ def update(self, flag_builder: FlagBuilderV2) -> TestDataV2: :param flag_builder: a flag configuration builder :return: self (the TestDataV2 object) """ + instances_copy = [] try: self._lock.lock() @@ -650,10 +651,13 @@ def update(self, flag_builder: FlagBuilderV2) -> TestDataV2: self._current_flags[flag_builder._key] = new_flag self._flag_builders[flag_builder._key] = flag_builder._copy() + + # Create a copy of instances while holding the lock to avoid race conditions + instances_copy = list(self._instances) finally: self._lock.unlock() - for instance in self._instances: + for instance in instances_copy: instance.upsert_flag(new_flag) return self