diff --git a/.circleci/config.yml b/.circleci/config.yml index f4cf6591..e3d5b29c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -93,8 +93,7 @@ jobs: name: verify typehints command: | export PATH="/home/circleci/.local/bin:$PATH" - mypy --install-types --non-interactive ldclient testing - mypy --config-file mypy.ini ldclient testing + make lint - unless: condition: <> diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32425905..8b956b9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,18 +32,64 @@ The additional requirements files `consul-requirements.txt`, `dynamodb-requireme ### Testing -To run all unit tests: +To run all unit tests except for the database integrations: +```shell +make test ``` -pytest -``` -By default, the full unit test suite includes live tests of the integrations for Consul, DynamoDB, and Redis. Those tests expect you to have instances of all of those databases running locally. To skip them, set the environment variable `LD_SKIP_DATABASE_TESTS=1` before running the tests. +To run all unit tests including the database integrations (this requires you to have instances of Consul, DynamoDB, and Redis running locally): + +```shell +make test-all +``` There are also integration tests that can be run against the LaunchDarkly service. To enable them, set the environment variable `LD_SDK_KEY` to a valid production SDK Key. -### Portability +It is preferable to run tests against all supported minor versions of Python (as described in `README.md` under Requirements), or at least the lowest and highest versions, prior to submitting a pull request. However, LaunchDarkly's CI tests will run automatically against all supported versions. -Most portability issues are addressed by using the `six` package. We are avoiding the use of `__future__` imports, since they can easily be omitted by mistake causing code in one file to behave differently from another; instead, whenever possible, use an explicit approach that makes it clear what the desired behavior is in all Python versions (e.g. if you want to do floor division, use `//`; if you want to divide as floats, explicitly cast to floats). +### Building documentation -It is preferable to run tests against all supported minor versions of Python (as described in `README.md` under Requirements), or at least the lowest and highest versions, prior to submitting a pull request. However, LaunchDarkly's CI tests will run automatically against all supported versions. +See "Documenting types and methods" below. To build the documentation locally, so you can see the effects of any changes before a release: + +```shell +make docs +``` + +The output will appear in `docs/build/html`. Its formatting will be somewhat different since it does not have the same stylesheets used on readthedocs.io. + +### Running the linter + +The `mypy` tool is used in CI to verify type hints and warn of potential code problems. To run it locally: + +```shell +make lint +``` + +## Code organization + +The SDK's module structure is as follows: + +* `ldclient`: This module exports the most commonly used classes and methods in the SDK, such as `LDClient`. The implementations may live in other modules, but applications should not need to import a more specific module such as `ldclient.client` to get those symbols. +* `ldclient.integrations`: This module contains entry points for optional features that are related to how the SDK communicates with other systems, such as `Redis`. +* `ldclient.interfaces`: This namespace contains types that do not do anything by themselves, but may need to be referenced if you are using optional features or implementing a custom component. + +A special case is the module `ldclient.impl`, and any modules within it. Everything under `impl` is considered a private implementation detail: all files there are excluded from the generated documentation, and are considered subject to change at any time and not supported for direct use by application developers. Alternately, class names can be prefixed with an underscore to be "private by convention"; that will at least prevent them from being included in wildcard imports like `from ldclient import *`, but it is still preferable to avoid a proliferation of implementation-only modules within the main `ldclient` module, since developers may wrongly decide to reference such modules in imports. + +So, if there is a class whose existence is entirely an implementation detail, it should be in `impl`. Similarly, classes that are _not_ in `impl` must not expose any public members (i.e. symbols that do not have an underscore prefix) that are not meant to be part of the supported public API. This is important because of our guarantee of backward compatibility for all public APIs within a major version: we want to be able to change our implementation details to suit the needs of the code, without worrying about breaking a customer's code. Due to how the language works, we can't actually prevent an application developer from referencing those classes in their code, but this convention makes it clear that such use is discouraged and unsupported. + +### Type hints + +Python does not require the use of type hints, but they can be extremely helpful for spotting mistakes and for improving the IDE experience, so we should always use them in the SDK. Every method in the public API is expected to have type hints for all non-`self` parameters, and for its return value if any. + +It's also desirable to use type hints for private attributes, to catch possible mistakes in their use. Until all versions of Python that we support allow the PEP 526 syntax for doing this, we must do it via a comment in the format that `mypy` understands, for instance: + +```python + self._some_attribute = None # type: Optional[int] +``` + +## Documenting types and methods + +All classes and public methods outside of `ldclient.impl` should have docstrings in Sphinx format. These are used to build the documentation that is published on [readthedocs.io](https://launchdarkly-python-sdk.readthedocs.io/). See the [Sphinx documentation](https://www.sphinx-doc.org/en/master/) for details of the docstring format. + +Please try to make the style and terminology in documentation comments consistent with other documentation comments in the SDK. Also, if a class or method is being added that has an equivalent in other SDKs, and if we have described it in a consistent away in those other SDKs, please reuse the text whenever possible (with adjustments for anything language-specific) rather than writing new text. diff --git a/Makefile b/Makefile index 730218e3..ca4fa068 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,21 @@ + +PYTEST_FLAGS=-W error::SyntaxWarning + +test: + LD_SKIP_DATABASE_TESTS=1 pytest $(PYTEST_FLAGS) + +test-all: + pytest $(PYTEST_FLAGS) + +lint: + mypy --install-types --non-interactive --config-file mypy.ini ldclient testing + +docs: + cd docs && make html + +.PHONY: test test-all lint docs + + TEMP_TEST_OUTPUT=/tmp/contract-test-service.log # port 8000 and 9000 is already used in the CI environment because we're diff --git a/docs/api-testing.rst b/docs/api-testing.rst new file mode 100644 index 00000000..c9faee05 --- /dev/null +++ b/docs/api-testing.rst @@ -0,0 +1,12 @@ +Test fixtures +============= + +ldclient.integrations.test_data module +-------------------------------------- + +The entry point for this feature is :class:`ldclient.integrations.test_data.TestData`. + +.. automodule:: ldclient.integrations.test_data + :members: + :special-members: __init__ + :show-inheritance: diff --git a/docs/index.rst b/docs/index.rst index 92c01ed2..aa03075e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,4 +19,5 @@ For more information, see LaunchDarkly's `Quickstart object: """Provides a way to use local files as a source of feature flag state. This would typically be used in a test environment, to operate using a predetermined feature flag state without an diff --git a/ldclient/integrations/test_data.py b/ldclient/integrations/test_data.py new file mode 100644 index 00000000..a159eb12 --- /dev/null +++ b/ldclient/integrations/test_data.py @@ -0,0 +1,548 @@ +import copy +from typing import Any, Dict, List, Optional, Union + +from ldclient.versioned_data_kind import FEATURES +from ldclient.rwlock import ReadWriteLock +from ldclient.impl.integrations.test_data.test_data_source import _TestDataSource + +TRUE_VARIATION_INDEX = 0 +FALSE_VARIATION_INDEX = 1 + +def _variation_for_boolean(variation): + if variation: + return TRUE_VARIATION_INDEX + else: + return FALSE_VARIATION_INDEX + +class TestData(): + """A mechanism for providing dynamically updatable feature flag state in a + simplified form to an SDK client in test scenarios. + + 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. + :: + + td = TestData.data_source() + td.update(td.flag('flag-key-1').variation_for_all_users(True)) + + client = LDClient(config=Config('SDK_KEY', update_processor_class = td)) + + # 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 ``FlagBuilder`` that is returned by ``flag``. ``FlagBuilder`` + 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 `TestData` 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 = [] + + def __call__(self, config, store, ready): + data_source = _TestDataSource(store, self) + try: + self._lock.lock() + self._instances.append(data_source) + finally: + self._lock.unlock() + + return data_source + + @staticmethod + def data_source() -> 'TestData': + """Creates a new instance of the test data source. + + :return: a new configurable test data source + """ + return TestData() + + def flag(self, key: str) -> 'FlagBuilder': + """Creates or copies a ``FlagBuilder`` for building a test flag configuration. + + If this flag key has already been defined in this ``TestData`` 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 ``FlagBuilder`` 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() + else: + return FlagBuilder(key).boolean_flag() + finally: + self._lock.runlock() + + def update(self, flag_builder: 'FlagBuilder') -> 'TestData': + """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 ``TestData``. 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 ``FlagBuilder`` instance do not affect the test data, + unless you call ``update`` again. + + :param flag_builder: a flag configuration builder + :return: self (the TestData 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(new_flag) + + return self + + def _make_init_data(self) -> dict: + return { FEATURES: copy.copy(self._current_flags) } + + def _closed_instance(self, instance): + try: + self._lock.lock() + self._instances.remove(instance) + finally: + self._lock.unlock() + +class FlagBuilder(): + """A builder for feature flag configurations to be used with :class:`ldclient.integrations.test_data.TestData`. + + :see: :meth:`ldclient.integrations.test_data.TestData.flag()` + :see: :meth:`ldclient.integrations.test_data.TestData.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[int, List[str]] + self._rules = [] # type: List[FlagRuleBuilder] + + # 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 TestData. + def _copy(self) -> 'FlagBuilder': + """Creates a deep copy of the flag builder. Subsequent updates to the + original ``FlagBuilder`` object will not update the copy and vise versa. + + :return: a copy of the flag builder object + """ + to = FlagBuilder(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 = copy.copy(self._targets) + to._rules = copy.copy(self._rules) + + return to + + def on(self, on: bool) -> 'FlagBuilder': + """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_data.TestData.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]) -> 'FlagBuilder': + """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 + else: + self._fallthrough_variation = variation + return self + + def off_variation(self, variation: Union[bool, int]) -> 'FlagBuilder' : + """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 + else: + self._off_variation = variation + return self + + def boolean_flag(self) -> 'FlagBuilder': + """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_data.TestData.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 + else: + 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] == True + and self._variations[FALSE_VARIATION_INDEX] == False) + + def variations(self, *variations) -> 'FlagBuilder': + """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 the desired variations + :return: the flag builder + """ + self._variations = list(variations) + + return self + + def variation_for_all_users(self, variation: Union[bool, int]) -> 'FlagBuilder': + """Sets the flag to always return the specified variation for all users. + + 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_users(_variation_for_boolean(variation)) + else: + return self.clear_rules().clear_targets().on(True).fallthrough_variation(variation) + + def value_for_all_users(self, value: Any) -> 'FlagBuilder': + """ + 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_users(0) + + def variation_for_user(self, user_key: str, variation: Union[bool, int]) -> 'FlagBuilder': + """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 + """ + if isinstance(variation, bool): + # `variation` is True/False value + return self.boolean_flag().variation_for_user(user_key, _variation_for_boolean(variation)) + else: + # `variation` specifies the index of the variation to set + targets = self._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 = [] # type: List[str] + if idx in targets: + target_for_variation = targets[idx] + + # If user is not in the current variation set, add them + if user_key not in target_for_variation: + target_for_variation.append(user_key) + + self._targets[idx] = target_for_variation + + else: + # Remove user from the other variation set if necessary + if idx in targets: + target_for_variation = targets[idx] + if user_key in target_for_variation: + user_key_idx = target_for_variation.index(user_key) + del target_for_variation[user_key_idx] + + self._targets[idx] = target_for_variation + + return self + + def _add_rule(self, flag_rule_builder: 'FlagRuleBuilder'): + self._rules.append(flag_rule_builder) + + def if_match(self, attribute: str, *values) -> 'FlagRuleBuilder': + """Starts defining a flag rule, using the "is one of" operator. + + **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 + """ + flag_rule_builder = FlagRuleBuilder(self) + return flag_rule_builder.and_match(attribute, *values) + + def if_not_match(self, attribute: str, *values) -> 'FlagRuleBuilder': + """Starts defining a flag rule, using the "is not one of" operator. + + **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 + """ + flag_rule_builder = FlagRuleBuilder(self) + return flag_rule_builder.and_not_match(attribute, values) + + def clear_rules(self) -> 'FlagBuilder': + """Removes any existing rules from the flag. + This undoes the effect of methods like + :meth:`ldclient.integrations.test_data.FlagBuilder.if_match()` + + :return: the same flag builder + """ + self._rules = [] + return self + + def clear_targets(self) -> 'FlagBuilder': + """Removes any existing targets from the flag. + This undoes the effect of methods like + :meth:`ldclient.integrations.test_data.FlagBuilder.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 TestData. + 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 + } + + base_flag_object['offVariation'] = self._off_variation + base_flag_object['fallthrough'] = { + 'variation': self._fallthrough_variation + } + + targets = [] + for var_index, user_keys in self._targets.items(): + targets.append({ + 'variation': var_index, + 'values': user_keys + }) + base_flag_object['targets'] = 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 FlagRuleBuilder(): + """ + A builder for feature flag rules to be used with :class:`ldclient.integrations.test_data.FlagBuilder`. + + 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_data.FlagBuilder.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_data.FlagRuleBuilder.and_match()` or + :meth:`ldclient.integrations.test_data.FlagRuleBuilder.and_not_match()`. + Finally, call :meth:`ldclient.integrations.test_data.FlagRuleBuilder.then_return()` + to finish defining the rule. + """ + def __init__(self, flag_builder: FlagBuilder): + self._flag_builder = flag_builder + self._clauses = [] # type: List[dict] + self._variation = None # type: Optional[int] + + def and_match(self, attribute: str, *values) -> 'FlagRuleBuilder': + """Adds another clause, using the "is one of" operator. + + **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 + """ + self._clauses.append({ + 'attribute': attribute, + 'operator': 'in', + 'values': list(values), + 'negate': False + }) + return self + + def and_not_match(self, attribute: str, *values) -> 'FlagRuleBuilder': + """Adds another clause, using the "is not one of" operator. + + **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 + """ + self._clauses.append({ + 'attribute': attribute, + 'operator': 'in', + 'values': list(values), + 'negate': True + }) + return self + + def then_return(self, variation: Union[bool, int]) -> 'FlagBuilder': + """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)) + else: + 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 FlagBuilder. + 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 + } diff --git a/testing/integrations/test_test_data_source.py b/testing/integrations/test_test_data_source.py new file mode 100644 index 00000000..e0db1208 --- /dev/null +++ b/testing/integrations/test_test_data_source.py @@ -0,0 +1,299 @@ +import pytest +import warnings + +from ldclient.client import LDClient +from ldclient.config import Config +from ldclient.feature_store import InMemoryFeatureStore +from ldclient.versioned_data_kind import FEATURES, SEGMENTS + +from ldclient.integrations.test_data import TestData + + +## Test Data + Data Source + +def test_makes_valid_datasource(): + td = TestData.data_source() + store = InMemoryFeatureStore() + + client = LDClient(config=Config('SDK_KEY', update_processor_class = td, send_events = False, offline = True, feature_store = store)) + + assert store.all(FEATURES, lambda x: x) == {} + + +def test_makes_valid_datasource_with_flag(): + td = TestData.data_source() + flag = td.flag(key='test-flag') + assert flag is not None + + builtFlag = flag._build(0) + assert builtFlag['key'] is 'test-flag' + assert builtFlag['on'] is True + assert builtFlag['variations'] == [True, False] + + +def test_can_retrieve_flag_from_store(): + td = TestData.data_source() + td.update(td.flag('some-flag')) + + store = InMemoryFeatureStore() + + client = LDClient(config=Config('SDK_KEY', update_processor_class = td, send_events = False, offline = True, feature_store = store)) + + assert store.get(FEATURES, 'some-flag') == td.flag('some-flag')._build(1) + + client.close() + +def test_updates_to_flags_are_reflected_in_store(): + td = TestData.data_source() + + store = InMemoryFeatureStore() + + client = LDClient(config=Config('SDK_KEY', update_processor_class = td, send_events = False, offline = True, feature_store = store)) + + td.update(td.flag('some-flag')) + + assert store.get(FEATURES, 'some-flag') == td.flag('some-flag')._build(1) + + client.close() + +def test_updates_after_client_close_have_no_affect(): + td = TestData.data_source() + + store = InMemoryFeatureStore() + + client = LDClient(config=Config('SDK_KEY', update_processor_class = td, send_events = False, offline = True, feature_store = store)) + + client.close() + + td.update(td.flag('some-flag')) + + assert store.get(FEATURES, 'some-flag') == None + +def test_can_handle_multiple_clients(): + td = TestData.data_source() + td.update(td.flag('flag')) + + store = InMemoryFeatureStore() + store2 = InMemoryFeatureStore() + + config = Config('SDK_KEY', update_processor_class = td, send_events = False, offline = True, feature_store = store) + client = LDClient(config=config) + + config2 = Config('SDK_KEY', update_processor_class = td, send_events = False, offline = True, feature_store = store2) + client2 = LDClient(config=config2) + + assert store.get(FEATURES, 'flag') == { + 'fallthrough': { + 'variation': 0, + }, + 'key': 'flag', + 'offVariation': 1, + 'on': True, + 'rules': [], + 'targets': [], + 'variations': [True, False], + 'version': 1 + } + + assert store2.get(FEATURES, 'flag') == { + 'fallthrough': { + 'variation': 0, + }, + 'key': 'flag', + 'offVariation': 1, + 'on': True, + 'rules': [], + 'targets': [], + 'variations': [True, False], + 'version': 1 + } + + td.update(td.flag('flag').variation_for_all_users(False)) + + assert store.get(FEATURES, 'flag') == { + 'fallthrough': { + 'variation': 1, + }, + 'key': 'flag', + 'offVariation': 1, + 'on': True, + 'rules': [], + 'targets': [], + 'variations': [True, False], + 'version': 2 + } + + assert store2.get(FEATURES, 'flag') == { + 'fallthrough': { + 'variation': 1, + }, + 'key': 'flag', + 'offVariation': 1, + 'on': True, + 'rules': [], + 'targets': [], + 'variations': [True, False], + 'version': 2 + } + + client.close() + client2.close() + + +## FlagBuilder + +def test_flagbuilder_defaults_to_boolean_flag(): + td = TestData.data_source() + flag = td.flag('empty-flag') + assert flag._build(0)['variations'] == [True, False] + assert flag._build(0)['fallthrough'] == {'variation': 0} + assert flag._build(0)['offVariation'] == 1 + +def test_flagbuilder_can_turn_flag_off(): + td = TestData.data_source() + flag = td.flag('test-flag') + flag.on(False) + + assert flag._build(0)['on'] is False + +def test_flagbuilder_can_set_fallthrough_variation(): + td = TestData.data_source() + flag = td.flag('test-flag') + flag.fallthrough_variation(2) + + assert flag._build(0)['fallthrough'] == {'variation': 2} + + flag.fallthrough_variation(True) + + assert flag._build(0)['fallthrough'] == {'variation': 0} + +def test_flagbuilder_can_set_off_variation(): + td = TestData.data_source() + flag = td.flag('test-flag') + flag.off_variation(2) + + assert flag._build(0)['offVariation'] == 2 + + flag.off_variation(True) + + assert flag._build(0)['offVariation'] == 0 + +def test_flagbuilder_can_make_boolean_flag(): + td = TestData.data_source() + flag = td.flag('boolean-flag').boolean_flag() + + builtFlag = flag._build(0) + assert builtFlag['fallthrough'] == {'variation': 0} + assert builtFlag['offVariation'] == 1 + +def test_flagbuilder_can_set_variation_when_targeting_is_off(): + td = TestData.data_source() + flag = td.flag('test-flag') \ + .on(False) + assert flag._build(0)['on'] == False + assert flag._build(0)['variations'] == [True,False] + flag.variations('dog', 'cat') + assert flag._build(0)['variations'] == ['dog','cat'] + +def test_flagbuilder_can_set_variation_for_all_users(): + td = TestData.data_source() + flag = td.flag('test-flag') + flag.variation_for_all_users(True) + assert flag._build(0)['fallthrough'] == {'variation': 0} + +def test_flagbuilder_clears_existing_rules_and_targets_when_setting_variation_for_all_users(): + td = TestData.data_source() + + flag = td.flag('test-flag').if_match('name', 'christian').then_return(False).variation_for_user('christian', False).variation_for_all_users(True)._build(0) + + assert flag['rules'] == [] + assert flag['targets'] == [] + +def test_flagbuilder_can_set_variations(): + td = TestData.data_source() + flag = td.flag('test-flag') + flag.variations(2,3,4,5) + assert flag._build(0)['variations'] == [2,3,4,5] + +def test_flagbuilder_can_make_an_immutable_copy(): + td = TestData.data_source() + flag = td.flag('test-flag') + flag.variations(1,2) + copy_of_flag = flag._copy() + flag.variations(3,4) + assert copy_of_flag._build(0)['variations'] == [1,2] + + copy_of_flag.variations(5,6) + assert flag._build(0)['variations'] == [3,4] + +def test_flagbuilder_can_set_boolean_variation_for_user(): + td = TestData.data_source() + flag = td.flag('user-variation-flag') + flag.variation_for_user('christian', False) + expected_targets = [ + { + 'variation': 1, + 'values': ['christian'] + } + ] + assert flag._build(0)['targets'] == expected_targets + +def test_flagbuilder_can_set_numerical_variation_for_user(): + td = TestData.data_source() + flag = td.flag('user-variation-flag') + flag.variations('a','b','c') + flag.variation_for_user('christian', 2) + expected_targets = [ + { + 'variation': 2, + 'values': ['christian'] + } + ] + assert flag._build(1)['targets'] == expected_targets + +def test_flagbuilder_can_set_value_for_all_users(): + td = TestData.data_source() + flag = td.flag('user-value-flag') + flag.variation_for_user('john', 1) + + built_flag = flag._build(0) + assert built_flag['targets'] == [{'values': ['john'], 'variation': 1}] + assert built_flag['variations'] == [True, False] + + flag.value_for_all_users('yes') + + built_flag2 = flag._build(0) + assert built_flag2['targets'] == [] + assert built_flag2['variations'] == ['yes'] + + +def test_flagbuilder_can_build(): + td = TestData.data_source() + flag = td.flag('some-flag') + flag.if_match('country', 'fr').then_return(True) + expected_result = { + 'fallthrough': { + 'variation': 0, + }, + 'key': 'some-flag', + 'offVariation': 1, + 'on': True, + 'targets': [], + 'variations': [True, False], + 'rules': [ + { + 'clauses': [ + {'attribute': 'country', + 'negate': False, + 'operator': 'in', + 'values': ['fr'] + } + ], + 'id': 'rule0', + 'variation': 0 + } + ], + 'version': 1, + } + + assert flag._build(1) == expected_result