From ca1a0bc7571d77f5f9adf8049e64564471237069 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 21 Dec 2021 08:56:11 +0000 Subject: [PATCH 01/21] Update setup.py --- setup.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index f2ec9a7..4717784 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ description="Flagsmith Python SDK", long_description=long_description, long_description_content_type="text/markdown", - author="Bullet Train Ltd", + author="Flagsmith", author_email="supoprt@flagsmith.com", license="BSD3", url="https://github.com/Flagsmith/flagsmith-python-client", @@ -20,9 +20,6 @@ ], classifiers=[ "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.6", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.0", "Programming Language :: Python :: 3.1", @@ -32,5 +29,8 @@ "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", ], ) From a4586bafe3d5f5c371d41e7f585394e1e06f770c Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 13 Jan 2022 21:48:09 +0000 Subject: [PATCH 02/21] Add publish workflow --- .github/workflows/publish.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5bbb713 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,26 @@ +name: Publish Pypi Package + +on: + push: + tags: + - '*' + +jobs: + package: + runs-on: ubuntu-latest + name: Publish Pypi Package + + steps: + - name: Cloning repo + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Build binary wheel and a source tarball + run: python setup.py sdist + + - name: Publish Package to Pypi + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} From dbf46fe6e68758e0826fd3377522e88970aa9a30 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 13 Jan 2022 21:48:53 +0000 Subject: [PATCH 03/21] Add test workflow --- .github/workflows/pytest.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 93d8009..c7310d0 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,4 +1,4 @@ -name: Pytest and Black formatting +name: Formatting and Tests on: - pull_request @@ -12,7 +12,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.6, 3.7, 3.8, 3.9, 3.10] steps: - name: Cloning repo @@ -34,5 +34,4 @@ jobs: run: black --check . - name: Run Tests - run: | - pytest \ No newline at end of file + run: pytest From 663b9a260c3e63fb5da226412672b617b7e8e355 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 13 Jan 2022 21:53:26 +0000 Subject: [PATCH 04/21] Rewrite basic python client functionality --- flagsmith/exceptions.py | 6 + flagsmith/flagsmith.py | 321 +++++++----------- flagsmith/models.py | 64 ++++ flagsmith/polling_manager.py | 27 ++ requirements-dev.txt | 4 +- requirements.txt | 1 + tests/conftest.py | 44 ++- tests/data/environment.json | 33 ++ tests/data/{get-flags.json => flags.json} | 11 +- ...et-flag-for-specific-feature-disabled.json | 17 - ...get-flag-for-specific-feature-enabled.json | 17 - tests/data/get-identity-flags-with-trait.json | 27 -- .../get-identity-flags-without-trait.json | 22 -- .../data/get-value-for-specific-feature.json | 17 - tests/data/identities.json | 29 ++ tests/data/not-found.json | 3 - tests/test_flagsmith.py | 261 ++++++++------ tests/test_polling_manager.py | 37 ++ 18 files changed, 527 insertions(+), 414 deletions(-) create mode 100644 flagsmith/exceptions.py create mode 100644 flagsmith/models.py create mode 100644 flagsmith/polling_manager.py create mode 100644 tests/data/environment.json rename tests/data/{get-flags.json => flags.json} (60%) delete mode 100644 tests/data/get-flag-for-specific-feature-disabled.json delete mode 100644 tests/data/get-flag-for-specific-feature-enabled.json delete mode 100644 tests/data/get-identity-flags-with-trait.json delete mode 100644 tests/data/get-identity-flags-without-trait.json delete mode 100644 tests/data/get-value-for-specific-feature.json create mode 100644 tests/data/identities.json delete mode 100644 tests/data/not-found.json create mode 100644 tests/test_polling_manager.py diff --git a/flagsmith/exceptions.py b/flagsmith/exceptions.py new file mode 100644 index 0000000..c1c3b36 --- /dev/null +++ b/flagsmith/exceptions.py @@ -0,0 +1,6 @@ +class FlagsmithClientError(Exception): + pass + + +class FlagsmithAPIError(FlagsmithClientError): + pass diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 714ef3f..8de139a 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -1,219 +1,154 @@ import logging +import typing +from json import JSONDecodeError import requests +from flag_engine import engine +from flag_engine.environments.builders import build_environment_model +from flag_engine.environments.models import EnvironmentModel +from flag_engine.identities.models import IdentityModel, TraitModel +from requests.adapters import HTTPAdapter, Retry -from .analytics import AnalyticsProcessor +from flagsmith.exceptions import FlagsmithAPIError, FlagsmithClientError +from flagsmith.models import Flags +from flagsmith.polling_manager import EnvironmentDataPollingManager logger = logging.getLogger(__name__) -SERVER_URL = "https://api.flagsmith.com/api/v1/" +API_URL = "https://api.flagsmith.com/api/v1/" FLAGS_ENDPOINT = "flags/" IDENTITY_ENDPOINT = "identities/" -TRAIT_ENDPOINT = "traits/" +TRAITS_ENDPOINT = "traits/" class Flagsmith: def __init__( - self, environment_id, api=SERVER_URL, custom_headers=None, request_timeout=None + self, + environment_key: str, + api_url: str = API_URL, + custom_headers: typing.Dict[str, typing.Any] = None, + request_timeout: int = None, + enable_client_side_evaluation: bool = False, + environment_refresh_interval_seconds: int = 10, + retries: Retry = None, ): - """ - Initialise Flagsmith environment. - - :param environment_id: environment key obtained from the Flagsmith UI - :param api: (optional) api url to override when using self hosted version - :param custom_headers: (optional) dict which will be passed in headers for each api call - :param request_timeout: (optional) request timeout in seconds - """ - - self.environment_id = environment_id - self.api = api - self.flags_endpoint = api + FLAGS_ENDPOINT - self.identities_endpoint = api + IDENTITY_ENDPOINT - self.traits_endpoint = api + TRAIT_ENDPOINT - self.custom_headers = custom_headers or {} - self.request_timeout = request_timeout - self._analytics_processor = AnalyticsProcessor( - environment_id, api, self.request_timeout + self.session = requests.Session() + self.session.headers.update( + **{"X-Environment-Key": environment_key}, **(custom_headers or {}) ) + retries = retries or Retry(total=3, backoff_factor=0.1) - def get_flags(self, identity=None): - """ - Get all flags for the environment or optionally provide an identity within an environment - to get their flags. Will return overridden identity flags where given and fill in the gaps - with the default environment flags. - - :param identity: application's unique identifier for the user to check feature states - :return: list of dictionaries representing feature states for environment / identity - """ - if identity: - data = self._get_flags_response(identity=identity) - else: - data = self._get_flags_response() - - if data: - return data - else: - logger.error("Failed to get flags for environment.") - - def get_flags_for_user(self, identity): - """ - Get all flags for a user - - :param identity: application's unique identifier for the user to check feature states - :return: list of dictionaries representing identities feature states for environment - """ - return self.get_flags(identity=identity) - - def has_feature(self, feature_name): - """ - Determine if given feature exists for an environment. - - :param feature_name: name of feature to test existence of - :return: True if exists, False if not. - """ - data = self._get_flags_response(feature_name) - if data: - feature_id = data["feature"]["id"] - self._analytics_processor.track_feature(feature_id) - return True - - return False - - def feature_enabled(self, feature_name, identity=None): - """ - Get enabled state of given feature for an environment. - - :param feature_name: name of feature to determine if enabled - :param identity: (optional) application's unique identifier for the user to check feature state - :return: True / False if feature exists. None otherwise. - """ - if not feature_name: - return None - - data = self._get_flags_response(feature_name, identity) - - if not data: - return None - - feature_id = data["feature"]["id"] - self._analytics_processor.track_feature(feature_id) - - return data["enabled"] - - def get_value(self, feature_name, identity=None): - """ - Get value of given feature for an environment. - - :param feature_name: name of feature to determine value of - :param identity: (optional) application's unique identifier for the user to check feature state - :return: value of the feature state if feature exists, None otherwise - """ - if not feature_name: - return None - - data = self._get_flags_response(feature_name, identity) - - if not data: - return None - feature_id = data["feature"]["id"] - self._analytics_processor.track_feature(feature_id) - return data["feature_state_value"] - - def get_trait(self, trait_key, identity): - """ - Get value of given trait for the identity of an environment. - - :param trait_key: key of trait to determine value of (must match 'ID' on flagsmith.com) - :param identity: application's unique identifier for the user to check feature state - :return: Trait value. None otherwise. - """ - if not all([trait_key, identity]): - return None - - data = self._get_flags_response(identity=identity, feature_name=None) - - traits = data["traits"] - for trait in traits: - if trait.get("trait_key") == trait_key: - return trait.get("trait_value") - - def set_trait(self, trait_key, trait_value, identity): - """ - Set value of given trait for the identity of an environment. Note that this will lazily create - a new trait if the trait_key has not been seen before for this identity - - :param trait_key: key of trait - :param trait_value: value of trait - :param identity: application's unique identifier for the user to check feature state - """ - values = [trait_key, trait_value, identity] - if None in values or "" in values: - return None - - payload = { - "identity": {"identifier": identity}, - "trait_key": trait_key, - "trait_value": trait_value, - } + self.api_url = api_url if api_url.endswith("/") else f"{api_url}/" + self.request_timeout = request_timeout + self.session.mount(self.api_url, HTTPAdapter(max_retries=retries)) + + self.environment_flags_url = f"{self.api_url}flags/" + self.identities_url = f"{self.api_url}identities/" + self.environment_url = f"{self.api_url}environment/" + + self._environment = None + if enable_client_side_evaluation: + self.environment_data_polling_manager_thread = ( + EnvironmentDataPollingManager( + main=self, + refresh_interval_seconds=environment_refresh_interval_seconds, + ) + ) + self.environment_data_polling_manager_thread.start() + + # TODO: analytics processor + + def get_environment_flags(self) -> Flags: + if self._environment: + return self._get_environment_flags_from_document() + return self._get_environment_flags_from_api() + + def get_identity_flags( + self, identifier: str, traits: typing.Dict[str, typing.Any] = None + ) -> Flags: + traits = traits or {} + if self._environment: + return self._get_identity_flags_from_document(identifier, traits) + return self._get_identity_flags_from_api(identifier, traits) + + def delete_identity_trait(self, identifier: str, trait_key: str) -> None: + # TODO: set the trait value to None and send to the API + pass + + def update_environment(self): + self._environment = self._get_environment_from_api() + + def _get_environment_from_api(self) -> EnvironmentModel: + environment_data = self._get_json_response(self.environment_url, method="GET") + return build_environment_model(environment_data) - requests.post( - self.traits_endpoint, - json=payload, - headers=self._generate_header_content(self.custom_headers), - timeout=self.request_timeout, + def _get_environment_flags_from_document(self) -> Flags: + return Flags.from_feature_state_models( + engine.get_environment_feature_states(self._environment) ) - def _get_flags_response(self, feature_name=None, identity=None): - """ - Private helper method to hit the flags endpoint + def _get_identity_flags_from_document( + self, identifier: str, traits: typing.Dict[str, typing.Any] + ) -> Flags: + identity_model = self._build_identity_model(identifier, **traits) + feature_states = engine.get_identity_feature_states( + self._environment, identity_model + ) + return Flags.from_feature_state_models( + feature_states, identity_model.composite_key + ) - :param feature_name: name of feature to determine value of (must match 'ID' on flagsmith.com) - :param identity: (optional) application's unique identifier for the user to check feature state - :return: data returned by API if successful, None if not. - """ - params = {"feature": feature_name} if feature_name else {} + def _get_environment_flags_from_api(self) -> Flags: + return Flags.from_api_flags( + self._get_json_response(url=self.environment_flags_url, method="GET") + ) + + def _get_identity_flags_from_api( + self, identifier: str, traits: typing.Dict[str, typing.Any] + ) -> Flags: + data = { + "identifier": identifier, + "traits": [ + {"trait_key": key, "trait_value": value} + for key, value in traits.items() + ], + } + json_response = self._get_json_response( + url=self.identities_url, method="POST", body=data + ) + return Flags.from_api_flags(json_response["flags"]) + def _get_json_response(self, url: str, method: str, body: dict = None): try: - if identity: - params["identifier"] = identity - response = requests.get( - self.identities_endpoint, - params=params, - headers=self._generate_header_content(self.custom_headers), - timeout=self.request_timeout, - ) - else: - response = requests.get( - self.flags_endpoint, - params=params, - headers=self._generate_header_content(self.custom_headers), - timeout=self.request_timeout, + request_method = getattr(self.session, method.lower()) + response = request_method(url, json=body, timeout=self.request_timeout) + if response.status_code != 200: + raise FlagsmithAPIError( + "Invalid request made to Flagsmith API. Response status code: %d", + response.status_code, ) - - if response.status_code == 200: - data = response.json() - if data: - return data - else: - logger.error("API didn't return any data") - return None - else: - return None - - except Exception as e: - logger.error( - "Got error getting response from API. Error message was %s" % e + return response.json() + except (requests.ConnectionError, JSONDecodeError) as e: + raise FlagsmithAPIError( + "Unable to get valid response from Flagsmith API." + ) from e + + def _build_identity_model(self, identifier: str, **traits): + if not self._environment: + raise FlagsmithClientError( + "Unable to build identity model when no local environment present." ) - return None - def _generate_header_content(self, headers=None): - """ - Generates required header content for accessing API - - :param headers: (optional) dictionary of other required header values - :return: dictionary with required environment header appended to it - """ - headers = headers or {} + trait_models = [ + TraitModel(trait_key=key, trait_value=value) + for key, value in traits.items() + ] + return IdentityModel( + identifier=identifier, + environment_api_key=self._environment.api_key, + identity_traits=trait_models, + ) - headers["X-Environment-Key"] = self.environment_id - return headers + def __del__(self): + if hasattr(self, "environment_data_polling_manager_thread"): + self.environment_data_polling_manager_thread.stop() diff --git a/flagsmith/models.py b/flagsmith/models.py new file mode 100644 index 0000000..508a6a6 --- /dev/null +++ b/flagsmith/models.py @@ -0,0 +1,64 @@ +import typing +from dataclasses import dataclass + +from flag_engine.features.models import FeatureStateModel + + +@dataclass +class Flag: + enabled: bool + value: typing.Any + feature_name: str + + @classmethod + def from_feature_state_model( + cls, feature_state_model: FeatureStateModel, identity_id: typing.Any = None + ) -> "Flag": + return Flag( + enabled=feature_state_model.enabled, + value=feature_state_model.get_value(identity_id=identity_id), + feature_name=feature_state_model.feature.name, + ) + + @classmethod + def from_api_flag(cls, flag_data: dict) -> "Flag": + return Flag( + enabled=flag_data["enabled"], + value=flag_data["feature_state_value"], + feature_name=flag_data["feature"]["name"], + ) + + +@dataclass +class Flags: + flags: typing.Dict[str, Flag] + + @classmethod + def from_feature_state_models( + cls, + feature_states: typing.List[FeatureStateModel], + identity_id: typing.Any = None, + ) -> "Flags": + return cls( + flags={ + feature_state.feature.name: Flag.from_feature_state_model( + feature_state, identity_id=identity_id + ) + for feature_state in feature_states + } + ) + + @classmethod + def from_api_flags(cls, flags: typing.List[dict]) -> "Flags": + return cls( + flags={ + flag_data["feature"]["name"]: Flag.from_api_flag(flag_data) + for flag_data in flags + } + ) + + def all_flags(self) -> typing.List[Flag]: + return list(self.flags.values()) + + def get_flag(self, feature_name: str) -> typing.Optional[Flag]: + return self.flags.get(feature_name) diff --git a/flagsmith/polling_manager.py b/flagsmith/polling_manager.py new file mode 100644 index 0000000..0aeb512 --- /dev/null +++ b/flagsmith/polling_manager.py @@ -0,0 +1,27 @@ +import threading +import time +import typing + +if typing.TYPE_CHECKING: + from flagsmith import Flagsmith + + +class EnvironmentDataPollingManager(threading.Thread): + def __init__( + self, main: "Flagsmith", refresh_interval_seconds: typing.Union[int, float] = 10 + ): + super(EnvironmentDataPollingManager, self).__init__() + self._stop_event = threading.Event() + self.main = main + self.refresh_interval_seconds = refresh_interval_seconds + + def run(self) -> None: + while not self._stop_event.is_set(): + self.main.update_environment() + time.sleep(self.refresh_interval_seconds) + + def stop(self) -> None: + self._stop_event.set() + + def __del__(self): + self._stop_event.set() diff --git a/requirements-dev.txt b/requirements-dev.txt index 518bcdc..4ab4469 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,6 @@ -r requirements.txt -pytest==5.1.2 +pytest +pytest-mock black pre-commit +responses \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index abffd05..76f0cac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ requests>=2.19.1 requests-futures==1.0.0 +flagsmith-flag-engine==1.5.1 diff --git a/tests/conftest.py b/tests/conftest.py index 7f0cc1c..0f86f6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,52 @@ +import json +import os +import random +import string + import pytest +from flag_engine.environments.builders import build_environment_model +from flagsmith import Flagsmith from flagsmith.analytics import AnalyticsProcessor +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") + -@pytest.fixture +@pytest.fixture() def analytics_processor(): return AnalyticsProcessor( environment_key="test_key", base_api_url="http://test_url" ) + + +@pytest.fixture(scope="session") +def api_key(): + return "".join(random.sample(string.ascii_letters, 20)) + + +@pytest.fixture() +def flagsmith(api_key): + return Flagsmith(environment_key=api_key) + + +@pytest.fixture() +def environment_json(): + with open(os.path.join(DATA_DIR, "environment.json"), "rt") as f: + yield f.read() + + +@pytest.fixture() +def environment_model(environment_json): + return build_environment_model(json.loads(environment_json)) + + +@pytest.fixture() +def flags_json(): + with open(os.path.join(DATA_DIR, "flags.json"), "rt") as f: + yield f.read() + + +@pytest.fixture() +def identities_json(): + with open(os.path.join(DATA_DIR, "identities.json"), "rt") as f: + yield f.read() diff --git a/tests/data/environment.json b/tests/data/environment.json new file mode 100644 index 0000000..d872ff1 --- /dev/null +++ b/tests/data/environment.json @@ -0,0 +1,33 @@ +{ + "api_key": "B62qaMZNwfiqT76p38ggrQ", + "project": { + "name": "Test project", + "organisation": { + "feature_analytics": false, + "name": "Test Org", + "id": 1, + "persist_trait_data": true, + "stop_serving_flags": false + }, + "id": 1, + "hide_disabled_flags": false, + "segments": [] + }, + "segment_overrides": [], + "id": 1, + "feature_states": [ + { + "multivariate_feature_state_values": [], + "feature_state_value": "some-value", + "id": 1, + "featurestate_uuid": "40eb539d-3713-4720-bbd4-829dbef10d51", + "feature": { + "name": "some_feature", + "type": "STANDARD", + "id": 1 + }, + "segment_id": null, + "enabled": true + } + ] +} \ No newline at end of file diff --git a/tests/data/get-flags.json b/tests/data/flags.json similarity index 60% rename from tests/data/get-flags.json rename to tests/data/flags.json index 6ffd5c5..cf06066 100644 --- a/tests/data/get-flags.json +++ b/tests/data/flags.json @@ -3,17 +3,18 @@ "id": 1, "feature": { "id": 1, - "name": "test", + "name": "some_feature", "created_date": "2019-08-27T14:53:45.698555Z", "initial_value": null, "description": null, "default_enabled": false, - "type": "FLAG", + "type": "STANDARD", "project": 1 }, - "feature_state_value": null, - "enabled": false, + "feature_state_value": "some-value", + "enabled": true, "environment": 1, - "identity": null + "identity": null, + "feature_segment": null } ] \ No newline at end of file diff --git a/tests/data/get-flag-for-specific-feature-disabled.json b/tests/data/get-flag-for-specific-feature-disabled.json deleted file mode 100644 index 38a8c58..0000000 --- a/tests/data/get-flag-for-specific-feature-disabled.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": 1, - "feature": { - "id": 1, - "name": "test", - "created_date": "2019-09-03T13:00:51.421698Z", - "initial_value": null, - "description": null, - "default_enabled": false, - "type": "FLAG", - "project": 1 - }, - "feature_state_value": null, - "enabled": false, - "environment": 1, - "identity": null -} \ No newline at end of file diff --git a/tests/data/get-flag-for-specific-feature-enabled.json b/tests/data/get-flag-for-specific-feature-enabled.json deleted file mode 100644 index f7f5205..0000000 --- a/tests/data/get-flag-for-specific-feature-enabled.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": 1, - "feature": { - "id": 1, - "name": "test", - "created_date": "2019-09-03T13:00:51.421698Z", - "initial_value": null, - "description": null, - "default_enabled": false, - "type": "FLAG", - "project": 1 - }, - "feature_state_value": null, - "enabled": true, - "environment": 1, - "identity": null -} \ No newline at end of file diff --git a/tests/data/get-identity-flags-with-trait.json b/tests/data/get-identity-flags-with-trait.json deleted file mode 100644 index 11fca91..0000000 --- a/tests/data/get-identity-flags-with-trait.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "flags": [ - { - "id": 1, - "feature": { - "id": 1, - "name": "test", - "created_date": "2019-09-03T13:00:51.421698Z", - "initial_value": null, - "description": null, - "default_enabled": false, - "type": "FLAG", - "project": 1 - }, - "feature_state_value": null, - "enabled": false, - "environment": 1, - "identity": null - } - ], - "traits": [ - { - "trait_key": "trait_key", - "trait_value": "trait_value" - } - ] -} \ No newline at end of file diff --git a/tests/data/get-identity-flags-without-trait.json b/tests/data/get-identity-flags-without-trait.json deleted file mode 100644 index c5d7a15..0000000 --- a/tests/data/get-identity-flags-without-trait.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "flags": [ - { - "id": 1, - "feature": { - "id": 1, - "name": "test", - "created_date": "2019-09-03T13:00:51.421698Z", - "initial_value": null, - "description": null, - "default_enabled": false, - "type": "FLAG", - "project": 1 - }, - "feature_state_value": null, - "enabled": false, - "environment": 1, - "identity": null - } - ], - "traits": [] -} \ No newline at end of file diff --git a/tests/data/get-value-for-specific-feature.json b/tests/data/get-value-for-specific-feature.json deleted file mode 100644 index fd5a871..0000000 --- a/tests/data/get-value-for-specific-feature.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": 1, - "feature": { - "id": 1, - "name": "test", - "created_date": "2019-09-03T13:00:51.421698Z", - "initial_value": null, - "description": null, - "default_enabled": false, - "type": "FLAG", - "project": 1 - }, - "feature_state_value": "Test value", - "enabled": false, - "environment": 1, - "identity": null -} \ No newline at end of file diff --git a/tests/data/identities.json b/tests/data/identities.json new file mode 100644 index 0000000..1d9c679 --- /dev/null +++ b/tests/data/identities.json @@ -0,0 +1,29 @@ +{ + "traits": [ + { + "id": 1, + "trait_key": "some_trait", + "trait_value": "some_value" + } + ], + "flags": [ + { + "id": 1, + "feature": { + "id": 1, + "name": "some_feature", + "created_date": "2019-08-27T14:53:45.698555Z", + "initial_value": null, + "description": null, + "default_enabled": false, + "type": "STANDARD", + "project": 1 + }, + "feature_state_value": "some-value", + "enabled": true, + "environment": 1, + "identity": null, + "feature_segment": null + } + ] +} \ No newline at end of file diff --git a/tests/data/not-found.json b/tests/data/not-found.json deleted file mode 100644 index 50cc092..0000000 --- a/tests/data/not-found.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "detail": "Given feature not found" -} \ No newline at end of file diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index b19df9d..ffd1f9d 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -1,151 +1,190 @@ import json -import logging -import os -from unittest import TestCase, mock +import uuid -from flagsmith import Flagsmith +import pytest +import requests +import responses +from flag_engine.features.models import FeatureStateModel, FeatureModel -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) +from flagsmith import Flagsmith +from flagsmith.exceptions import FlagsmithAPIError -TEST_API_URL = "https://test.bullet-train.io/api" -TEST_IDENTIFIER = "test-identity" -TEST_FEATURE = "test-feature" +def test_flagsmith_starts_polling_manager_on_init_if_enabled(mocker, api_key): + # Given + mock_polling_manager = mocker.MagicMock() + mocker.patch( + "flagsmith.flagsmith.EnvironmentDataPollingManager", + return_value=mock_polling_manager, + ) -class MockResponse: - def __init__(self, data, status_code): - self.json_data = json.loads(data) - self.status_code = status_code + # When + Flagsmith(environment_key=api_key, enable_client_side_evaluation=True) - def json(self): - return self.json_data + # Then + mock_polling_manager.start.assert_called_once() -def mock_response(filename, *args, status=200, **kwargs): - print("Hit URL %s with params" % args[0], kwargs.get("params")) - dir_path = os.path.dirname(os.path.realpath(__file__)) - with open(os.path.join(dir_path, filename), "rt") as f: - return MockResponse(f.read(), status) +@responses.activate() +def test_update_environment_sets_environment( + flagsmith, environment_json, environment_model +): + # Given + responses.add(method="GET", url=flagsmith.environment_url, body=environment_json) + assert flagsmith._environment is None + # When + flagsmith.update_environment() -def mocked_get_specific_feature_flag_enabled(*args, **kwargs): - return mock_response( - "data/get-flag-for-specific-feature-enabled.json", *args, **kwargs - ) + # Then + assert flagsmith._environment is not None + assert flagsmith._environment == environment_model -def mocked_get_specific_feature_flag_disabled(*args, **kwargs): - return mock_response( - "data/get-flag-for-specific-feature-disabled.json", *args, **kwargs - ) +@responses.activate() +def test_get_environment_flags_calls_api_when_no_local_environment( + api_key, flagsmith, flags_json +): + # Given + responses.add(method="GET", url=flagsmith.environment_flags_url, body=flags_json) + # When + all_flags = flagsmith.get_environment_flags().all_flags() -def mocked_get_specific_feature_flag_not_found(*args, **kwargs): - return mock_response("data/not-found.json", *args, status=404, **kwargs) + # Then + assert len(responses.calls) == 1 + assert responses.calls[0].request.headers["X-Environment-Key"] == api_key + # Taken from hard coded values in tests/data/flags.json + assert all_flags[0].enabled is True + assert all_flags[0].value == "some-value" + assert all_flags[0].feature_name == "some_feature" -def mocked_get_value(*args, **kwargs): - return mock_response("data/get-value-for-specific-feature.json", *args, **kwargs) +@responses.activate() +def test_get_environment_flags_uses_local_environment_when_available( + flagsmith, environment_model +): + # Given + flagsmith._environment = environment_model -def mocked_get_identity_flags_with_trait(*args, **kwargs): - return mock_response("data/get-identity-flags-with-trait.json", *args, **kwargs) + # When + all_flags = flagsmith.get_environment_flags().all_flags() + # Then + assert len(responses.calls) == 0 + assert len(all_flags) == 1 + assert all_flags[0].feature_name == environment_model.feature_states[0].feature.name + assert all_flags[0].enabled == environment_model.feature_states[0].enabled + assert all_flags[0].value == environment_model.feature_states[0].get_value() -def mocked_get_identity_flags_without_trait(*args, **kwargs): - return mock_response("data/get-identity-flags-without-trait.json", *args, **kwargs) +@responses.activate() +def test_get_identity_flags_calls_api_when_no_local_environment_no_traits( + flagsmith, identities_json +): + # Given + responses.add(method="POST", url=flagsmith.identities_url, body=identities_json) + identifier = "identifier" -class FlagsmithTestCase(TestCase): - test_environment_key = "test-env-key" + # When + identity_flags = flagsmith.get_identity_flags(identifier=identifier).all_flags() - def setUp(self) -> None: - self.bt = Flagsmith(environment_id=self.test_environment_key, api=TEST_API_URL) + # Then + assert responses.calls[0].request.body.decode() == json.dumps( + {"identifier": identifier, "traits": []} + ) - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_specific_feature_flag_enabled, + # Taken from hard coded values in tests/data/identities.json + assert identity_flags[0].enabled is True + assert identity_flags[0].value == "some-value" + assert identity_flags[0].feature_name == "some_feature" + + +@responses.activate() +def test_get_identity_flags_calls_api_when_no_local_environment_no_traits( + flagsmith, identities_json +): + # Given + responses.add(method="POST", url=flagsmith.identities_url, body=identities_json) + identifier = "identifier" + traits = {"some_trait": "some_value"} + + # When + identity_flags = flagsmith.get_identity_flags(identifier=identifier, traits=traits) + + # Then + assert responses.calls[0].request.body.decode() == json.dumps( + { + "identifier": identifier, + "traits": [{"trait_key": k, "trait_value": v} for k, v in traits.items()], + } ) - def test_has_feature_returns_true_if_feature_returned(self, mock_get): - # When - result = self.bt.has_feature(TEST_FEATURE) - # Then - assert result + # Taken from hard coded values in tests/data/identities.json + assert identity_flags.all_flags()[0].enabled is True + assert identity_flags.all_flags()[0].value == "some-value" + assert identity_flags.all_flags()[0].feature_name == "some_feature" - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_specific_feature_flag_not_found, - ) - def test_has_feature_returns_false_if_feature_not_returned(self, mock_get): - # When - result = self.bt.has_feature(TEST_FEATURE) - # Then - assert not result +@responses.activate() +def test_get_identity_flags_uses_local_environment_when_available( + flagsmith, environment_model, mocker +): + # Given + flagsmith._environment = environment_model + mock_engine = mocker.patch("flagsmith.flagsmith.engine") - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_specific_feature_flag_enabled, + feature_state = FeatureStateModel( + feature=FeatureModel(id=1, name="some_feature", type="STANDARD"), + enabled=True, + featurestate_uuid=str(uuid.uuid4()), ) - def test_feature_enabled_returns_true_if_feature_enabled(self, mock_get): - # When - result = self.bt.feature_enabled(TEST_FEATURE) + mock_engine.get_identity_feature_states.return_value = [feature_state] - # Then - assert result + # When + identity_flags = flagsmith.get_identity_flags( + "identifier", traits={"some_trait": "some_value"} + ).all_flags() - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_specific_feature_flag_disabled, - ) - def test_feature_enabled_returns_true_if_feature_disabled(self, mock_get): - # When - result = self.bt.feature_enabled(TEST_FEATURE) + # Then + mock_engine.get_identity_feature_states.assert_called_once() + assert identity_flags[0].enabled is feature_state.enabled + assert identity_flags[0].value == feature_state.get_value() - # Then - assert not result - @mock.patch("flagsmith.flagsmith.requests.get", side_effect=mocked_get_value) - def test_get_value_returns_value_for_environment_if_feature_exists(self, mock_get): - # When - result = self.bt.get_value(TEST_FEATURE) +def test_request_connection_error_raises_flagsmith_api_error(mocker, api_key): + """ + Test the behaviour when session. raises a ConnectionError. Note that this + does not account for the fact that we are using retries. Since this is a standard + library, we leave this untested. It is assumed that, once the retries are exhausted, + the requests library raises requests.ConnectionError. + """ - # Then - assert result == "Test value" + # Given + mock_session = mocker.MagicMock() + mocker.patch("flagsmith.flagsmith.requests.Session", return_value=mock_session) - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_specific_feature_flag_not_found, - ) - def test_get_value_returns_None_for_environment_if_feature_does_not_exist( - self, mock_get - ): - # When - result = self.bt.get_value(TEST_FEATURE) - - # Then - assert result is None - - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_identity_flags_with_trait, - ) - def test_get_trait_returns_trait_value_if_trait_key_exists(self, mock_get): - # When - result = self.bt.get_trait("trait_key", TEST_IDENTIFIER) + flagsmith = Flagsmith(environment_key=api_key) - # Then - assert result == "trait_value" + mock_session.get.side_effect = requests.ConnectionError - @mock.patch( - "flagsmith.flagsmith.requests.get", - side_effect=mocked_get_identity_flags_without_trait, - ) - def test_get_trait_returns_None_if_trait_key_does_not_exist(self, mock_get): - # When - result = self.bt.get_trait("trait_key", TEST_IDENTIFIER) + # When + with pytest.raises(FlagsmithAPIError): + flagsmith.get_environment_flags() + + # Then + # expected exception raised + + +@responses.activate() +def test_non_200_response_raises_flagsmith_api_error(flagsmith): + # Given + responses.add(url=flagsmith.environment_flags_url, method="GET", status=400) + + # When + with pytest.raises(FlagsmithAPIError): + flagsmith.get_environment_flags() - # Then - assert result is None + # Then + # expected exception raised diff --git a/tests/test_polling_manager.py b/tests/test_polling_manager.py new file mode 100644 index 0000000..b4185cc --- /dev/null +++ b/tests/test_polling_manager.py @@ -0,0 +1,37 @@ +import time +from unittest import mock + +from flagsmith.polling_manager import EnvironmentDataPollingManager + + +def test_polling_manager_calls_update_environment_on_start(): + # Given + flagsmith = mock.MagicMock() + polling_manager = EnvironmentDataPollingManager( + main=flagsmith, refresh_interval_seconds=0.1 + ) + + # When + polling_manager.start() + + # Then + flagsmith.update_environment.assert_called_once() + polling_manager.stop() + + +def test_polling_manager_calls_update_environment_on_each_refresh(): + # Given + flagsmith = mock.MagicMock() + polling_manager = EnvironmentDataPollingManager( + main=flagsmith, refresh_interval_seconds=0.1 + ) + + # When + polling_manager.start() + time.sleep(0.25) + + # Then + # 3 calls to update_environment should be made, one when the thread starts and 2 + # for each subsequent refresh + assert flagsmith.update_environment.call_count == 3 + polling_manager.stop() From fdf19464ab07e7addc34a0d5d27a253b0916515a Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 25 Jan 2022 15:46:28 +0000 Subject: [PATCH 05/21] Add analytics processor logic --- flagsmith/analytics.py | 1 - flagsmith/flagsmith.py | 19 ++++++++++++++----- flagsmith/models.py | 32 ++++++++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/flagsmith/analytics.py b/flagsmith/analytics.py index 5003699..8055631 100644 --- a/flagsmith/analytics.py +++ b/flagsmith/analytics.py @@ -32,7 +32,6 @@ def __init__(self, environment_key: str, base_api_url: str, timeout: int = 3): self._last_flushed = datetime.now() self.analytics_data = {} self.timeout = timeout - super().__init__() def flush(self): """ diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 8de139a..87053dd 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -9,6 +9,7 @@ from flag_engine.identities.models import IdentityModel, TraitModel from requests.adapters import HTTPAdapter, Retry +from flagsmith.analytics import AnalyticsProcessor from flagsmith.exceptions import FlagsmithAPIError, FlagsmithClientError from flagsmith.models import Flags from flagsmith.polling_manager import EnvironmentDataPollingManager @@ -56,7 +57,9 @@ def __init__( ) self.environment_data_polling_manager_thread.start() - # TODO: analytics processor + self._analytics_processor = AnalyticsProcessor( + environment_key, self.api_url, timeout=self.request_timeout + ) def get_environment_flags(self) -> Flags: if self._environment: @@ -84,7 +87,8 @@ def _get_environment_from_api(self) -> EnvironmentModel: def _get_environment_flags_from_document(self) -> Flags: return Flags.from_feature_state_models( - engine.get_environment_feature_states(self._environment) + feature_states=engine.get_environment_feature_states(self._environment), + analytics_processor=self._analytics_processor, ) def _get_identity_flags_from_document( @@ -95,12 +99,15 @@ def _get_identity_flags_from_document( self._environment, identity_model ) return Flags.from_feature_state_models( - feature_states, identity_model.composite_key + feature_states=feature_states, + analytics_processor=self._analytics_processor, + identity_id=identity_model.composite_key, ) def _get_environment_flags_from_api(self) -> Flags: return Flags.from_api_flags( - self._get_json_response(url=self.environment_flags_url, method="GET") + flags=self._get_json_response(url=self.environment_flags_url, method="GET"), + analytics_processor=self._analytics_processor, ) def _get_identity_flags_from_api( @@ -116,7 +123,9 @@ def _get_identity_flags_from_api( json_response = self._get_json_response( url=self.identities_url, method="POST", body=data ) - return Flags.from_api_flags(json_response["flags"]) + return Flags.from_api_flags( + flags=json_response["flags"], analytics_processor=self._analytics_processor + ) def _get_json_response(self, url: str, method: str, body: dict = None): try: diff --git a/flagsmith/models.py b/flagsmith/models.py index 508a6a6..1d5addf 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -3,12 +3,16 @@ from flag_engine.features.models import FeatureStateModel +from flagsmith.analytics import AnalyticsProcessor +from flagsmith.exceptions import FlagsmithClientError + @dataclass class Flag: enabled: bool value: typing.Any feature_name: str + feature_id: int @classmethod def from_feature_state_model( @@ -18,6 +22,7 @@ def from_feature_state_model( enabled=feature_state_model.enabled, value=feature_state_model.get_value(identity_id=identity_id), feature_name=feature_state_model.feature.name, + feature_id=feature_state_model.feature.id, ) @classmethod @@ -26,17 +31,20 @@ def from_api_flag(cls, flag_data: dict) -> "Flag": enabled=flag_data["enabled"], value=flag_data["feature_state_value"], feature_name=flag_data["feature"]["name"], + feature_id=flag_data["feature"]["id"], ) @dataclass class Flags: flags: typing.Dict[str, Flag] + _analytics_processor: AnalyticsProcessor @classmethod def from_feature_state_models( cls, feature_states: typing.List[FeatureStateModel], + analytics_processor: AnalyticsProcessor, identity_id: typing.Any = None, ) -> "Flags": return cls( @@ -45,20 +53,36 @@ def from_feature_state_models( feature_state, identity_id=identity_id ) for feature_state in feature_states - } + }, + _analytics_processor=analytics_processor, ) @classmethod - def from_api_flags(cls, flags: typing.List[dict]) -> "Flags": + def from_api_flags( + cls, flags: typing.List[dict], analytics_processor: AnalyticsProcessor + ) -> "Flags": return cls( flags={ flag_data["feature"]["name"]: Flag.from_api_flag(flag_data) for flag_data in flags - } + }, + _analytics_processor=analytics_processor, ) def all_flags(self) -> typing.List[Flag]: return list(self.flags.values()) + def is_feature_enabled(self, feature_name: str) -> bool: + return self.get_flag(feature_name).enabled + + def get_feature_value(self, feature_name: str) -> bool: + return self.get_flag(feature_name).value + def get_flag(self, feature_name: str) -> typing.Optional[Flag]: - return self.flags.get(feature_name) + try: + flag = self.flags[feature_name] + except KeyError: + raise FlagsmithClientError("Feature does not exist: %s" % feature_name) + + self._analytics_processor.track_feature(flag.feature_id) + return flag From e80eec898dc3df3ac2f2b32afec8857eec98fe79 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 25 Jan 2022 16:04:41 +0000 Subject: [PATCH 06/21] Add some docstrings and util function --- flagsmith/flagsmith.py | 29 ++++++++++++++++++----------- flagsmith/models.py | 28 +++++++++++++++++++++++++++- flagsmith/utils/__init__.py | 0 flagsmith/utils/identities.py | 5 +++++ 4 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 flagsmith/utils/__init__.py create mode 100644 flagsmith/utils/identities.py diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 87053dd..12470d8 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -13,6 +13,7 @@ from flagsmith.exceptions import FlagsmithAPIError, FlagsmithClientError from flagsmith.models import Flags from flagsmith.polling_manager import EnvironmentDataPollingManager +from flagsmith.utils.identities import generate_identities_data logger = logging.getLogger(__name__) @@ -62,6 +63,11 @@ def __init__( ) def get_environment_flags(self) -> Flags: + """ + Get all the default for flags for the current environment. + + :return: Flags object holding all the flags for the current environment. + """ if self._environment: return self._get_environment_flags_from_document() return self._get_environment_flags_from_api() @@ -69,15 +75,22 @@ def get_environment_flags(self) -> Flags: def get_identity_flags( self, identifier: str, traits: typing.Dict[str, typing.Any] = None ) -> Flags: + """ + Get all the flags for the current environment for a given identity. Will also + upsert all traits to the Flagsmith API for future evaluations. Providing a + trait with a value of None will remove the trait from the identity if it exists. + + :param identifier: a unique identifier for the identity in the current + environment, e.g. email address, username, uuid + :param traits: a dictionary of traits to add / update on the identity in + Flagsmith, e.g. {"num_orders": 10} + :return: Flags object holding all the flags for the given identity. + """ traits = traits or {} if self._environment: return self._get_identity_flags_from_document(identifier, traits) return self._get_identity_flags_from_api(identifier, traits) - def delete_identity_trait(self, identifier: str, trait_key: str) -> None: - # TODO: set the trait value to None and send to the API - pass - def update_environment(self): self._environment = self._get_environment_from_api() @@ -113,13 +126,7 @@ def _get_environment_flags_from_api(self) -> Flags: def _get_identity_flags_from_api( self, identifier: str, traits: typing.Dict[str, typing.Any] ) -> Flags: - data = { - "identifier": identifier, - "traits": [ - {"trait_key": key, "trait_value": value} - for key, value in traits.items() - ], - } + data = generate_identities_data(identifier, traits) json_response = self._get_json_response( url=self.identities_url, method="POST", body=data ) diff --git a/flagsmith/models.py b/flagsmith/models.py index 1d5addf..3f18d0d 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -70,15 +70,41 @@ def from_api_flags( ) def all_flags(self) -> typing.List[Flag]: + """ + Get a list of all Flag objects. + + :return: list of Flag objects. + """ return list(self.flags.values()) def is_feature_enabled(self, feature_name: str) -> bool: + """ + Check whether a given feature is enabled. + + :param feature_name: the name of the feature to check if enabled. + :return: Boolean representing the enabled state of a given feature. + :raises FlagsmithClientError: if feature doesn't exist + """ return self.get_flag(feature_name).enabled - def get_feature_value(self, feature_name: str) -> bool: + def get_feature_value(self, feature_name: str) -> typing.Any: + """ + Get the value of a particular feature. + + :param feature_name: the name of the feature to retrieve the value of. + :return: the value of the given feature. + :raises FlagsmithClientError: if feature doesn't exist + """ return self.get_flag(feature_name).value def get_flag(self, feature_name: str) -> typing.Optional[Flag]: + """ + Get a specific flag given the feature name. + + :param feature_name: the name of the feature to retrieve the flag for. + :return: Flag object. + :raises FlagsmithClientError: if feature doesn't exist + """ try: flag = self.flags[feature_name] except KeyError: diff --git a/flagsmith/utils/__init__.py b/flagsmith/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/flagsmith/utils/identities.py b/flagsmith/utils/identities.py new file mode 100644 index 0000000..855b0f9 --- /dev/null +++ b/flagsmith/utils/identities.py @@ -0,0 +1,5 @@ +def generate_identities_data(identifier: str, traits: dict = None): + return { + "identifier": identifier, + "traits": [{"trait_key": k, "trait_value": v} for k, v in traits.items()] + } From ba63381f1324ac85d41f97d1189fc821c764dfee Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 25 Jan 2022 16:42:46 +0000 Subject: [PATCH 07/21] Move to poetry for dependency management and builds --- .github/workflows/publish.yml | 12 +- .github/workflows/pytest.yml | 8 +- poetry.lock | 741 ++++++++++++++++++++++++++++++++++ pyproject.toml | 28 ++ requirements-dev.txt | 6 - requirements.txt | 3 - setup.cfg | 2 - setup.py | 36 -- 8 files changed, 780 insertions(+), 56 deletions(-) create mode 100644 poetry.lock create mode 100644 pyproject.toml delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5bbb713..8c8324f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,11 +16,9 @@ jobs: with: fetch-depth: 0 - - name: Build binary wheel and a source tarball - run: python setup.py sdist - - - name: Publish Package to Pypi - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Build and publish to pypi + uses: JRubics/poetry-publish@v1.10 with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + pypi_token: ${{ secrets.PYPI_API_TOKEN }} + ignore_dev_requirements: "yes" + build_format: "sdist" diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c7310d0..ed6a1fa 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -28,10 +28,14 @@ jobs: - name: Install Dependencies run: | python -m pip install --upgrade pip - pip install -r requirements-dev.txt + pip install poetry + poetry install - name: Check Formatting - run: black --check . + run: | + black --check . + flake8 . + isort . --check - name: Run Tests run: pytest diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..05a0b07 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,741 @@ +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "black" +version = "21.12b0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +click = ">=7.1.2" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0,<1" +platformdirs = ">=2" +tomli = ">=0.2.6,<2.0.0" +typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} +typing-extensions = [ + {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}, + {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""}, +] + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +python2 = ["typed-ast (>=1.4.3)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2021.10.8" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "cfgv" +version = "3.3.1" +description = "Validate configuration and produce human readable error messages." +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[[package]] +name = "charset-normalizer" +version = "2.0.10" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.0.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "distlib" +version = "0.3.4" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "filelock" +version = "3.4.2" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] +testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] + +[[package]] +name = "flagsmith-flag-engine" +version = "1.5.1" +description = "Flag engine for the Flagsmith API." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +marshmallow = ">=3.14.1" + +[[package]] +name = "flake8" +version = "4.0.1" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +importlib-metadata = {version = "<4.3", markers = "python_version < \"3.8\""} +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.8.0,<2.9.0" +pyflakes = ">=2.4.0,<2.5.0" + +[[package]] +name = "identify" +version = "2.4.5" +description = "File identification library for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "importlib-metadata" +version = "4.2.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "isort" +version = "5.10.1" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] + +[[package]] +name = "marshmallow" +version = "3.14.1" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pytest", "pytz", "simplejson", "mypy (==0.910)", "flake8 (==4.0.1)", "flake8-bugbear (==21.9.2)", "pre-commit (>=2.4,<3.0)", "tox"] +docs = ["sphinx (==4.3.0)", "sphinx-issues (==1.2.0)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.7)"] +lint = ["mypy (==0.910)", "flake8 (==4.0.1)", "flake8-bugbear (==21.9.2)", "pre-commit (>=2.4,<3.0)"] +tests = ["pytest", "pytz", "simplejson"] + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "nodeenv" +version = "1.6.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pathspec" +version = "0.9.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[[package]] +name = "platformdirs" +version = "2.4.1" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "2.17.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +toml = "*" +virtualenv = ">=20.0.8" + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pycodestyle" +version = "2.8.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyflakes" +version = "2.4.0" +description = "passive checker of Python programs" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyparsing" +version = "3.0.7" +description = "Python parsing module" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pytest" +version = "6.2.5" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "pytest-mock" +version = "3.6.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "tox", "pytest-asyncio"] + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "requests" +version = "2.27.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + +[[package]] +name = "requests-futures" +version = "1.0.0" +description = "Asynchronous Python HTTP for Humans." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +requests = ">=1.2.0" + +[[package]] +name = "responses" +version = "0.17.0" +description = "A utility library for mocking out the `requests` Python library." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +requests = ">=2.0" +six = "*" +urllib3 = ">=1.25.10" + +[package.extras] +tests = ["coverage (>=3.7.1,<6.0.0)", "pytest-cov", "pytest-localserver", "flake8", "types-mock", "types-requests", "types-six", "pytest (>=4.6,<5.0)", "pytest (>=4.6)", "mypy"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "1.2.3" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "typed-ast" +version = "1.5.2" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "typing-extensions" +version = "4.0.1" +description = "Backported and Experimental Type Hints for Python 3.6+" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "urllib3" +version = "1.26.8" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "virtualenv" +version = "20.13.0" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + +[package.dependencies] +distlib = ">=0.3.1,<1" +filelock = ">=3.2,<4" +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +platformdirs = ">=2,<3" +six = ">=1.9.0,<2" + +[package.extras] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] + +[[package]] +name = "zipp" +version = "3.7.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] + +[metadata] +lock-version = "1.1" +python-versions = ">=3.7.0,<4" +content-hash = "296ed13a67def18ad7949ad3b9be19670b977cf9a914def96f371e4569116d3e" + +[metadata.files] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +] +black = [ + {file = "black-21.12b0-py3-none-any.whl", hash = "sha256:a615e69ae185e08fdd73e4715e260e2479c861b5740057fde6e8b4e3b7dd589f"}, + {file = "black-21.12b0.tar.gz", hash = "sha256:77b80f693a569e2e527958459634f18df9b0ba2625ba4e0c2d5da5be42e6f2b3"}, +] +certifi = [ + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, +] +cfgv = [ + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"}, + {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"}, +] +click = [ + {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, + {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +distlib = [ + {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, + {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, +] +filelock = [ + {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, + {file = "filelock-3.4.2.tar.gz", hash = "sha256:38b4f4c989f9d06d44524df1b24bd19e167d851f19b50bf3e3559952dddc5b80"}, +] +flagsmith-flag-engine = [ + {file = "flagsmith-flag-engine-1.5.1.tar.gz", hash = "sha256:5e9b1ca75bc50df68379afb7c39d5d2edc91fcde4bda15b67b7573e4efc9fe45"}, +] +flake8 = [ + {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, + {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, +] +identify = [ + {file = "identify-2.4.5-py2.py3-none-any.whl", hash = "sha256:d27d10099844741c277b45d809bd452db0d70a9b41ea3cd93799ebbbcc6dcb29"}, + {file = "identify-2.4.5.tar.gz", hash = "sha256:d11469ff952a4d7fd7f9be520d335dc450f585d474b39b5dfb86a500831ab6c7"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +importlib-metadata = [ + {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"}, + {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, +] +isort = [ + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, +] +marshmallow = [ + {file = "marshmallow-3.14.1-py3-none-any.whl", hash = "sha256:04438610bc6dadbdddb22a4a55bcc7f6f8099e69580b2e67f5a681933a1f4400"}, + {file = "marshmallow-3.14.1.tar.gz", hash = "sha256:4c05c1684e0e97fe779c62b91878f173b937fe097b356cd82f793464f5bc6138"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +nodeenv = [ + {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, + {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pathspec = [ + {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, + {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, +] +platformdirs = [ + {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, + {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, +] +pluggy = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] +pre-commit = [ + {file = "pre_commit-2.17.0-py2.py3-none-any.whl", hash = "sha256:725fa7459782d7bec5ead072810e47351de01709be838c2ce1726b9591dad616"}, + {file = "pre_commit-2.17.0.tar.gz", hash = "sha256:c1a8040ff15ad3d648c70cc3e55b93e4d2d5b687320955505587fd79bbaed06a"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pycodestyle = [ + {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, + {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, +] +pyflakes = [ + {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, + {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, +] +pyparsing = [ + {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, + {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, +] +pytest = [ + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, +] +pytest-mock = [ + {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, + {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, +] +pyyaml = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] +requests = [ + {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, + {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, +] +requests-futures = [ + {file = "requests-futures-1.0.0.tar.gz", hash = "sha256:35547502bf1958044716a03a2f47092a89efe8f9789ab0c4c528d9c9c30bc148"}, + {file = "requests_futures-1.0.0-py2.py3-none-any.whl", hash = "sha256:633804c773b960cef009efe2a5585483443c6eac3c39cc64beba2884013bcdd9"}, +] +responses = [ + {file = "responses-0.17.0-py2.py3-none-any.whl", hash = "sha256:e4fc472fb7374fb8f84fcefa51c515ca4351f198852b4eb7fc88223780b472ea"}, + {file = "responses-0.17.0.tar.gz", hash = "sha256:ec675e080d06bf8d1fb5e5a68a1e5cd0df46b09c78230315f650af5e4036bec7"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +tomli = [ + {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, + {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, +] +typed-ast = [ + {file = "typed_ast-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:183b183b7771a508395d2cbffd6db67d6ad52958a5fdc99f450d954003900266"}, + {file = "typed_ast-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:676d051b1da67a852c0447621fdd11c4e104827417bf216092ec3e286f7da596"}, + {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc2542e83ac8399752bc16e0b35e038bdb659ba237f4222616b4e83fb9654985"}, + {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74cac86cc586db8dfda0ce65d8bcd2bf17b58668dfcc3652762f3ef0e6677e76"}, + {file = "typed_ast-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:18fe320f354d6f9ad3147859b6e16649a0781425268c4dde596093177660e71a"}, + {file = "typed_ast-1.5.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:31d8c6b2df19a777bc8826770b872a45a1f30cfefcfd729491baa5237faae837"}, + {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:963a0ccc9a4188524e6e6d39b12c9ca24cc2d45a71cfdd04a26d883c922b4b78"}, + {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0eb77764ea470f14fcbb89d51bc6bbf5e7623446ac4ed06cbd9ca9495b62e36e"}, + {file = "typed_ast-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:294a6903a4d087db805a7656989f613371915fc45c8cc0ddc5c5a0a8ad9bea4d"}, + {file = "typed_ast-1.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26a432dc219c6b6f38be20a958cbe1abffcc5492821d7e27f08606ef99e0dffd"}, + {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7407cfcad702f0b6c0e0f3e7ab876cd1d2c13b14ce770e412c0c4b9728a0f88"}, + {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f30ddd110634c2d7534b2d4e0e22967e88366b0d356b24de87419cc4410c41b7"}, + {file = "typed_ast-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8c08d6625bb258179b6e512f55ad20f9dfef019bbfbe3095247401e053a3ea30"}, + {file = "typed_ast-1.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90904d889ab8e81a956f2c0935a523cc4e077c7847a836abee832f868d5c26a4"}, + {file = "typed_ast-1.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bbebc31bf11762b63bf61aaae232becb41c5bf6b3461b80a4df7e791fabb3aca"}, + {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29dd9a3a9d259c9fa19d19738d021632d673f6ed9b35a739f48e5f807f264fb"}, + {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:58ae097a325e9bb7a684572d20eb3e1809802c5c9ec7108e85da1eb6c1a3331b"}, + {file = "typed_ast-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:da0a98d458010bf4fe535f2d1e367a2e2060e105978873c04c04212fb20543f7"}, + {file = "typed_ast-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:33b4a19ddc9fc551ebabca9765d54d04600c4a50eda13893dadf67ed81d9a098"}, + {file = "typed_ast-1.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1098df9a0592dd4c8c0ccfc2e98931278a6c6c53cb3a3e2cf7e9ee3b06153344"}, + {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c47c3b43fe3a39ddf8de1d40dbbfca60ac8530a36c9b198ea5b9efac75c09e"}, + {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f290617f74a610849bd8f5514e34ae3d09eafd521dceaa6cf68b3f4414266d4e"}, + {file = "typed_ast-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:df05aa5b241e2e8045f5f4367a9f6187b09c4cdf8578bb219861c4e27c443db5"}, + {file = "typed_ast-1.5.2.tar.gz", hash = "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27"}, +] +typing-extensions = [ + {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, + {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, +] +urllib3 = [ + {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, + {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, +] +virtualenv = [ + {file = "virtualenv-20.13.0-py2.py3-none-any.whl", hash = "sha256:339f16c4a86b44240ba7223d0f93a7887c3ca04b5f9c8129da7958447d079b09"}, + {file = "virtualenv-20.13.0.tar.gz", hash = "sha256:d8458cf8d59d0ea495ad9b34c2599487f8a7772d796f9910858376d1600dd2dd"}, +] +zipp = [ + {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, + {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1ebecb9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[tool.poetry] +name = "flagsmith" +version = "3.0.0" +description = "Flagsmith Python SDK" +authors = ["Flagsmith "] +license = "BSD3" +readme = "Readme.md" +keywords = ["feature", "flag", "flagsmith", "remote", "config"] +documentation = "https://docs.flagsmith.com" + +[tool.poetry.dependencies] +python = ">=3.7.0,<4" +requests = "^2.27.1" +requests-futures = "^1.0.0" +flagsmith-flag-engine = "^1.5.1" + +[tool.poetry.dev-dependencies] +pytest = "^6.2.5" +pytest-mock = "^3.6.1" +black = "^21.12b0" +pre-commit = "^2.17.0" +responses = "^0.17.0" +flake8 = "^4.0.1" +isort = "^5.10.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 4ab4469..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,6 +0,0 @@ --r requirements.txt -pytest -pytest-mock -black -pre-commit -responses \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 76f0cac..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -requests>=2.19.1 -requests-futures==1.0.0 -flagsmith-flag-engine==1.5.1 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 0bc5bd0..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = Readme.md diff --git a/setup.py b/setup.py deleted file mode 100644 index 4717784..0000000 --- a/setup.py +++ /dev/null @@ -1,36 +0,0 @@ -from setuptools import setup - -with open("Readme.md", "r") as readme: - long_description = readme.read() - -setup( - name="flagsmith", - version="3.0.0", - packages=["flagsmith"], - description="Flagsmith Python SDK", - long_description=long_description, - long_description_content_type="text/markdown", - author="Flagsmith", - author_email="supoprt@flagsmith.com", - license="BSD3", - url="https://github.com/Flagsmith/flagsmith-python-client", - keywords=["feature", "flag", "flagsmith", "remote", "config"], - install_requires=[ - "requests>=2.19.1", - ], - classifiers=[ - "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.0", - "Programming Language :: Python :: 3.1", - "Programming Language :: Python :: 3.2", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - ], -) From 74ca7bd525f8db41642a82ecd92a68d3cee543c7 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 25 Jan 2022 16:48:18 +0000 Subject: [PATCH 08/21] Formatting --- .flake8 | 4 ++++ .github/workflows/pytest.yml | 2 +- flagsmith/__init__.py | 2 +- flagsmith/utils/identities.py | 2 +- tests/test_analytics.py | 2 +- tests/test_flagsmith.py | 4 ++-- 6 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..fbfd088 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-line-length = 120 +max-complexity = 10 +exclude = .git,__pycache__,.venv diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index ed6a1fa..d3c8ddc 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -35,7 +35,7 @@ jobs: run: | black --check . flake8 . - isort . --check + isort --check . - name: Run Tests run: pytest diff --git a/flagsmith/__init__.py b/flagsmith/__init__.py index b1da5ce..46571d5 100644 --- a/flagsmith/__init__.py +++ b/flagsmith/__init__.py @@ -1 +1 @@ -from .flagsmith import Flagsmith +from .flagsmith import Flagsmith # noqa diff --git a/flagsmith/utils/identities.py b/flagsmith/utils/identities.py index 855b0f9..9c82333 100644 --- a/flagsmith/utils/identities.py +++ b/flagsmith/utils/identities.py @@ -1,5 +1,5 @@ def generate_identities_data(identifier: str, traits: dict = None): return { "identifier": identifier, - "traits": [{"trait_key": k, "trait_value": v} for k, v in traits.items()] + "traits": [{"trait_key": k, "trait_value": v} for k, v in traits.items()], } diff --git a/tests/test_analytics.py b/tests/test_analytics.py index d6e70ea..2b3cc68 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from unittest import mock -from flagsmith.analytics import ANALYTICS_TIMER, AnalyticsProcessor +from flagsmith.analytics import ANALYTICS_TIMER def test_analytics_processor_track_feature_updates_analytics_data(analytics_processor): diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index ffd1f9d..ac1a04c 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -4,7 +4,7 @@ import pytest import requests import responses -from flag_engine.features.models import FeatureStateModel, FeatureModel +from flag_engine.features.models import FeatureModel, FeatureStateModel from flagsmith import Flagsmith from flagsmith.exceptions import FlagsmithAPIError @@ -102,7 +102,7 @@ def test_get_identity_flags_calls_api_when_no_local_environment_no_traits( @responses.activate() -def test_get_identity_flags_calls_api_when_no_local_environment_no_traits( +def test_get_identity_flags_calls_api_when_no_local_environment_with_traits( flagsmith, identities_json ): # Given From deef9ada695e5b998fc1e6d16457f94fd6f6fbca Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 25 Jan 2022 16:49:27 +0000 Subject: [PATCH 09/21] Remove duplicate triggers for pytest workflow --- .github/workflows/pytest.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index d3c8ddc..3d9302f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -2,7 +2,6 @@ name: Formatting and Tests on: - pull_request - - push jobs: test: From 3c1f55dd0803713e64f03ccdccf05788635a915e Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 25 Jan 2022 16:53:04 +0000 Subject: [PATCH 10/21] Fix pipeline --- .github/workflows/pytest.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 3d9302f..47f38d0 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -11,7 +11,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.6, 3.7, 3.8, 3.9, 3.10] + python-version: ["3.7", "3.8", "3.9", "3.10"] steps: - name: Cloning repo @@ -32,9 +32,9 @@ jobs: - name: Check Formatting run: | - black --check . - flake8 . - isort --check . + poetry run black --check . + poetry run flake8 . + poetry run isort --check . - name: Run Tests - run: pytest + run: poetry run pytest From 8e411a7526ebe4e3eaf67cf0ee996b0f15dc12d2 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 25 Jan 2022 18:01:33 +0000 Subject: [PATCH 11/21] Only include flagsmith package --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 1ebecb9..3b6a989 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,9 @@ license = "BSD3" readme = "Readme.md" keywords = ["feature", "flag", "flagsmith", "remote", "config"] documentation = "https://docs.flagsmith.com" +packages = [ + {include = "flagsmith"}, +] [tool.poetry.dependencies] python = ">=3.7.0,<4" From dd787125bc2e0102437f72701432d00d1cd5bf1b Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 25 Jan 2022 18:03:53 +0000 Subject: [PATCH 12/21] Update example app --- example/.env | 2 ++ example/app.py | 26 +++++++++++++++++ example/example.py | 58 ------------------------------------- example/readme.md | 26 +++++++++-------- example/requirements.txt | 2 ++ example/templates/home.html | 8 +++++ 6 files changed, 52 insertions(+), 70 deletions(-) create mode 100644 example/.env create mode 100644 example/app.py delete mode 100644 example/example.py create mode 100644 example/requirements.txt create mode 100644 example/templates/home.html diff --git a/example/.env b/example/.env new file mode 100644 index 0000000..7af4488 --- /dev/null +++ b/example/.env @@ -0,0 +1,2 @@ +FLASK_APP=app +FLAGSMITH_ENVIRONMENT_KEY= \ No newline at end of file diff --git a/example/app.py b/example/app.py new file mode 100644 index 0000000..c2f6ade --- /dev/null +++ b/example/app.py @@ -0,0 +1,26 @@ +import json +import os + +from flask import Flask, render_template + +from flagsmith import Flagsmith + +app = Flask(__name__) + +flagsmith = Flagsmith(environment_key=os.environ.get("FLAGSMITH_ENVIRONMENT_KEY")) + + +@app.route("/") +def hello_world(): + flags = flagsmith.get_environment_flags() + identity_flags = flagsmith.get_identity_flags("identifier") + + show_button = flags.is_feature_enabled( + "secret_button" + ) and identity_flags.is_feature_enabled("secret_button") + + button_data = json.loads(flags.get_feature_value("secret_button")) + + return render_template( + "home.html", show_button=show_button, button_colour=button_data["colour"] + ) diff --git a/example/example.py b/example/example.py deleted file mode 100644 index e8e9c11..0000000 --- a/example/example.py +++ /dev/null @@ -1,58 +0,0 @@ -import json - -from flagsmith import Flagsmith - -api_key = input("Please provide an environment api key: ") - -flagsmith = Flagsmith(environment_id=api_key) - -identifier = input("Please provide an example identity: ") -feature_name = input("Please provide an example feature name: ") - -print_get_flags = input("Print result of get_flags for environment? (y/n) ") -if print_get_flags.lower() == "y": - print(json.dumps(flagsmith.get_flags(), indent=2)) - -print_get_flags_with_identity = input("Print result of get_flags with identity? (y/n) ") -if print_get_flags_with_identity.lower() == "y": - print(json.dumps(flagsmith.get_flags(identifier), indent=2)) - -print_get_flags_for_user = input("Print result of get_flags_for_user? (y/n) ") -if print_get_flags_for_user.lower() == "y": - print(json.dumps(flagsmith.get_flags_for_user(identifier), indent=2)) - -print_get_value_of_feature_for_environment = input( - "Print result of get_value for environment? (y/n) " -) -if print_get_value_of_feature_for_environment.lower() == "y": - print(flagsmith.get_value(feature_name)) - -print_get_value_of_feature_for_environment = input( - "Print result of get_value for identity? (y/n) " -) -if print_get_value_of_feature_for_environment.lower() == "y": - print(flagsmith.get_value(feature_name, identity=identifier)) - -print_result_of_has_feature = input("Print result of has feature? (y/n) ") -if print_result_of_has_feature.lower() == "y": - print(flagsmith.has_feature(feature_name)) - -print_result_of_feature_enabled_for_environment = input( - "Print result of feature_enabled for environment? (y/n) " -) -if print_result_of_feature_enabled_for_environment.lower() == "y": - print(flagsmith.feature_enabled(feature_name)) - -print_result_of_feature_enabled_for_identity = input( - "Print result of feature_enabled for identity? (y/n) " -) -if print_result_of_feature_enabled_for_identity.lower() == "y": - print(flagsmith.feature_enabled(feature_name, identity=identifier)) - -set_trait = input("Would you like to test traits? (y/n) ") -if set_trait.lower() == "y": - trait_key = input("Trait key: ") - trait_value = input("Trait value: ") - flagsmith.set_trait(trait_key, trait_value, identifier) - print("Trait set successfully") - print("Result from get_trait is %s" % flagsmith.get_trait(trait_key, identifier)) diff --git a/example/readme.md b/example/readme.md index 2a2300c..297086d 100644 --- a/example/readme.md +++ b/example/readme.md @@ -1,19 +1,21 @@ # Flagsmith Basic Python Example -To use this basic example, you'll need to first configure a project with at least one feature in Flagsmith. +This directory contains a basic Flask application which utilises Flagsmith. To run the example application, you'll +need to go through the following steps: -Once you've done this, you'll then need to install the latest version of the Flagsmith package by running: +1. Create an account, organisation and project on [Flagsmith](https://flagsmith.com) +2. Create a feature in the project called "secret_button" +3. Give the feature a value using the json editor as follows: -```bash -pip install flagsmith +```json +{"colour": "#ababab"} ``` -Then you can run: +4. Update the .env file located in this directory with the environment key of one of the environments in flagsmith ( +This can be found on the 'settings' page accessed from the menu on the left under the chosen environment.) +5. From a terminal window, export those environment variables (either manually or by using `export $(cat .env)`) +6. Run the app using `flask run` +7. Browse to http://localhost:5000 -```bash -python example.py -``` - -The script will grab some information from you such as the environment key to test with, an identifier and a feature -name. Once you've inputted those, the script will run you through all of the methods available in the Flagsmith -client and print the result. +Now you can play around with the 'secret_button' feature in flagsmith, turn it on to show it and edit the colour in the +json value to edit the colour of the button. diff --git a/example/requirements.txt b/example/requirements.txt new file mode 100644 index 0000000..3731c99 --- /dev/null +++ b/example/requirements.txt @@ -0,0 +1,2 @@ +flask==2.0.2 +flagsmith>=3.0.0 \ No newline at end of file diff --git a/example/templates/home.html b/example/templates/home.html new file mode 100644 index 0000000..8fbc127 --- /dev/null +++ b/example/templates/home.html @@ -0,0 +1,8 @@ + +Flagsmith Example + +

Hello, World.

+{% if show_button %} + +{% endif %} + \ No newline at end of file From 4c560f0227a95598ab1e5d0c5195a0643e7fbac6 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 25 Jan 2022 18:05:06 +0000 Subject: [PATCH 13/21] Add some todos --- flagsmith/flagsmith.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 12470d8..752754e 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -22,6 +22,10 @@ IDENTITY_ENDPOINT = "identities/" TRAITS_ENDPOINT = "traits/" +# TODO: +# - defaults +# - disable analytics + class Flagsmith: def __init__( From 4bd5a614fca5b11265c2af9aaad8e97b20041a78 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 25 Jan 2022 18:05:51 +0000 Subject: [PATCH 14/21] Change default refresh interval --- flagsmith/flagsmith.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 752754e..9f08325 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -35,7 +35,7 @@ def __init__( custom_headers: typing.Dict[str, typing.Any] = None, request_timeout: int = None, enable_client_side_evaluation: bool = False, - environment_refresh_interval_seconds: int = 10, + environment_refresh_interval_seconds: int = 60, retries: Retry = None, ): self.session = requests.Session() From 14d05bc7105bb445437affc8b73fdaa00a0382fd Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 26 Jan 2022 09:50:26 +0000 Subject: [PATCH 15/21] Add ability to enable / disable analytics --- flagsmith/flagsmith.py | 10 +++++++--- flagsmith/models.py | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 9f08325..875cf3a 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -24,7 +24,6 @@ # TODO: # - defaults -# - disable analytics class Flagsmith: @@ -37,6 +36,7 @@ def __init__( enable_client_side_evaluation: bool = False, environment_refresh_interval_seconds: int = 60, retries: Retry = None, + enable_analytics: bool = False, ): self.session = requests.Session() self.session.headers.update( @@ -62,8 +62,12 @@ def __init__( ) self.environment_data_polling_manager_thread.start() - self._analytics_processor = AnalyticsProcessor( - environment_key, self.api_url, timeout=self.request_timeout + self._analytics_processor = ( + AnalyticsProcessor( + environment_key, self.api_url, timeout=self.request_timeout + ) + if enable_analytics + else None ) def get_environment_flags(self) -> Flags: diff --git a/flagsmith/models.py b/flagsmith/models.py index 3f18d0d..81b28ab 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -38,7 +38,7 @@ def from_api_flag(cls, flag_data: dict) -> "Flag": @dataclass class Flags: flags: typing.Dict[str, Flag] - _analytics_processor: AnalyticsProcessor + _analytics_processor: AnalyticsProcessor = None @classmethod def from_feature_state_models( @@ -110,5 +110,7 @@ def get_flag(self, feature_name: str) -> typing.Optional[Flag]: except KeyError: raise FlagsmithClientError("Feature does not exist: %s" % feature_name) - self._analytics_processor.track_feature(flag.feature_id) + if self._analytics_processor: + self._analytics_processor.track_feature(flag.feature_id) + return flag From 395b73deb458fa9bd66ef9162e98d08e601f55ba Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 26 Jan 2022 10:23:27 +0000 Subject: [PATCH 16/21] Add defaults --- flagsmith/flagsmith.py | 28 +++++++----- flagsmith/models.py | 62 +++++++++++++++++--------- tests/test_flagsmith.py | 98 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 33 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 875cf3a..5d3243a 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -11,32 +11,27 @@ from flagsmith.analytics import AnalyticsProcessor from flagsmith.exceptions import FlagsmithAPIError, FlagsmithClientError -from flagsmith.models import Flags +from flagsmith.models import DefaultFlag, Flags from flagsmith.polling_manager import EnvironmentDataPollingManager from flagsmith.utils.identities import generate_identities_data logger = logging.getLogger(__name__) -API_URL = "https://api.flagsmith.com/api/v1/" -FLAGS_ENDPOINT = "flags/" -IDENTITY_ENDPOINT = "identities/" -TRAITS_ENDPOINT = "traits/" - -# TODO: -# - defaults +DEFAULT_API_URL = "https://api.flagsmith.com/api/v1/" class Flagsmith: def __init__( self, environment_key: str, - api_url: str = API_URL, + api_url: str = DEFAULT_API_URL, custom_headers: typing.Dict[str, typing.Any] = None, request_timeout: int = None, enable_client_side_evaluation: bool = False, environment_refresh_interval_seconds: int = 60, retries: Retry = None, enable_analytics: bool = False, + defaults: typing.List[DefaultFlag] = None, ): self.session = requests.Session() self.session.headers.update( @@ -70,6 +65,8 @@ def __init__( else None ) + self.defaults = defaults or [] + def get_environment_flags(self) -> Flags: """ Get all the default for flags for the current environment. @@ -110,6 +107,7 @@ def _get_environment_flags_from_document(self) -> Flags: return Flags.from_feature_state_models( feature_states=engine.get_environment_feature_states(self._environment), analytics_processor=self._analytics_processor, + defaults=self.defaults, ) def _get_identity_flags_from_document( @@ -123,12 +121,18 @@ def _get_identity_flags_from_document( feature_states=feature_states, analytics_processor=self._analytics_processor, identity_id=identity_model.composite_key, + defaults=self.defaults, ) def _get_environment_flags_from_api(self) -> Flags: + api_flags = self._get_json_response( + url=self.environment_flags_url, method="GET" + ) + return Flags.from_api_flags( - flags=self._get_json_response(url=self.environment_flags_url, method="GET"), + api_flags=api_flags, analytics_processor=self._analytics_processor, + defaults=self.defaults, ) def _get_identity_flags_from_api( @@ -139,7 +143,9 @@ def _get_identity_flags_from_api( url=self.identities_url, method="POST", body=data ) return Flags.from_api_flags( - flags=json_response["flags"], analytics_processor=self._analytics_processor + api_flags=json_response["flags"], + analytics_processor=self._analytics_processor, + defaults=self.defaults, ) def _get_json_response(self, url: str, method: str, body: dict = None): diff --git a/flagsmith/models.py b/flagsmith/models.py index 81b28ab..a102c15 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -8,11 +8,21 @@ @dataclass -class Flag: +class BaseFlag: enabled: bool value: typing.Any feature_name: str + + +@dataclass +class DefaultFlag(BaseFlag): + is_default = True + + +@dataclass +class Flag(BaseFlag): feature_id: int + is_default = False @classmethod def from_feature_state_model( @@ -37,7 +47,7 @@ def from_api_flag(cls, flag_data: dict) -> "Flag": @dataclass class Flags: - flags: typing.Dict[str, Flag] + flags: typing.Dict[str, BaseFlag] _analytics_processor: AnalyticsProcessor = None @classmethod @@ -46,30 +56,38 @@ def from_feature_state_models( feature_states: typing.List[FeatureStateModel], analytics_processor: AnalyticsProcessor, identity_id: typing.Any = None, + defaults: typing.List[DefaultFlag] = None, ) -> "Flags": - return cls( - flags={ - feature_state.feature.name: Flag.from_feature_state_model( - feature_state, identity_id=identity_id - ) - for feature_state in feature_states - }, - _analytics_processor=analytics_processor, - ) + flags = { + feature_state.feature.name: Flag.from_feature_state_model( + feature_state, identity_id=identity_id + ) + for feature_state in feature_states + } + + for default in defaults or []: + flags.setdefault(default.feature_name, default) + + return cls(flags=flags, _analytics_processor=analytics_processor) @classmethod def from_api_flags( - cls, flags: typing.List[dict], analytics_processor: AnalyticsProcessor + cls, + api_flags: typing.List[dict], + analytics_processor: AnalyticsProcessor, + defaults: typing.List[DefaultFlag] = None, ) -> "Flags": - return cls( - flags={ - flag_data["feature"]["name"]: Flag.from_api_flag(flag_data) - for flag_data in flags - }, - _analytics_processor=analytics_processor, - ) + flags = { + flag_data["feature"]["name"]: Flag.from_api_flag(flag_data) + for flag_data in api_flags + } + + for default in defaults or []: + flags.setdefault(default.feature_name, default) + + return cls(flags=flags, _analytics_processor=analytics_processor) - def all_flags(self) -> typing.List[Flag]: + def all_flags(self) -> typing.List[BaseFlag]: """ Get a list of all Flag objects. @@ -97,7 +115,7 @@ def get_feature_value(self, feature_name: str) -> typing.Any: """ return self.get_flag(feature_name).value - def get_flag(self, feature_name: str) -> typing.Optional[Flag]: + def get_flag(self, feature_name: str) -> typing.Optional[BaseFlag]: """ Get a specific flag given the feature name. @@ -110,7 +128,7 @@ def get_flag(self, feature_name: str) -> typing.Optional[Flag]: except KeyError: raise FlagsmithClientError("Feature does not exist: %s" % feature_name) - if self._analytics_processor: + if self._analytics_processor and hasattr(flag, "feature_id"): self._analytics_processor.track_feature(flag.feature_id) return flag diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index ac1a04c..95d681f 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -8,6 +8,7 @@ from flagsmith import Flagsmith from flagsmith.exceptions import FlagsmithAPIError +from flagsmith.models import DefaultFlag def test_flagsmith_starts_polling_manager_on_init_if_enabled(mocker, api_key): @@ -188,3 +189,100 @@ def test_non_200_response_raises_flagsmith_api_error(flagsmith): # Then # expected exception raised + + +@responses.activate() +def test_default_flag_is_used_when_no_environment_flags_returned(api_key): + # Given + # a default flag + default_flag = DefaultFlag( + enabled=True, value="some-default-value", feature_name="some_feature" + ) + flagsmith = Flagsmith(environment_key=api_key, defaults=[default_flag]) + + # and we mock the API to return an empty list of flags + responses.add( + url=flagsmith.environment_flags_url, method="GET", body=json.dumps([]) + ) + + # When + flags = flagsmith.get_environment_flags() + + # Then + # the data from the default flag is used + flag = flags.get_flag(default_flag.feature_name) + assert flag.enabled == default_flag.enabled + assert flag.value == default_flag.value + assert flag.feature_name == default_flag.feature_name + + +@responses.activate() +def test_default_flag_is_not_used_when_environment_flags_returned(api_key, flags_json): + # Given + # A default flag + default_flag = DefaultFlag( + enabled=True, value="some-default-value", feature_name="some_feature" + ) + flagsmith = Flagsmith(environment_key=api_key, defaults=[default_flag]) + + # but we mock the API to return an actual value for the same feature + responses.add(url=flagsmith.environment_flags_url, method="GET", body=flags_json) + + # When + flags = flagsmith.get_environment_flags() + + # Then + # the data from the API response is used, not the default flag + flag = flags.get_flag(default_flag.feature_name) + assert flag.value != default_flag.value + assert flag.value == "some-value" # hard coded value in tests/data/flags.json + + +@responses.activate() +def test_default_flag_is_used_when_no_identity_flags_returned(api_key): + # Given + # a default flag + default_flag = DefaultFlag( + enabled=True, value="some-default-value", feature_name="some_feature" + ) + flagsmith = Flagsmith(environment_key=api_key, defaults=[default_flag]) + + # and we mock the API to return an empty list of flags + response_data = {"flags": [], "traits": []} + responses.add( + url=flagsmith.identities_url, method="POST", body=json.dumps(response_data) + ) + + # When + flags = flagsmith.get_identity_flags(identifier="identifier") + + # Then + # the data from the default flag is used + flag = flags.get_flag(default_flag.feature_name) + assert flag.enabled == default_flag.enabled + assert flag.value == default_flag.value + assert flag.feature_name == default_flag.feature_name + + +@responses.activate() +def test_default_flag_is_not_used_when_identity_flags_returned( + api_key, identities_json +): + # Given + # A default flag + default_flag = DefaultFlag( + enabled=True, value="some-default-value", feature_name="some_feature" + ) + flagsmith = Flagsmith(environment_key=api_key, defaults=[default_flag]) + + # but we mock the API to return an actual value for the same feature + responses.add(url=flagsmith.identities_url, method="POST", body=identities_json) + + # When + flags = flagsmith.get_identity_flags(identifier="identifier") + + # Then + # the data from the API response is used, not the default flag + flag = flags.get_flag(default_flag.feature_name) + assert flag.value != default_flag.value + assert flag.value == "some-value" # hard coded value in tests/data/identities.json From 1a58c7b273854c5f8449d22efcc2d646aa65b3e3 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 26 Jan 2022 11:01:56 +0000 Subject: [PATCH 17/21] Typehinting updates --- flagsmith/models.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/flagsmith/models.py b/flagsmith/models.py index a102c15..0b12260 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -10,7 +10,7 @@ @dataclass class BaseFlag: enabled: bool - value: typing.Any + value: typing.Union[str, int, float, bool] feature_name: str @@ -26,7 +26,9 @@ class Flag(BaseFlag): @classmethod def from_feature_state_model( - cls, feature_state_model: FeatureStateModel, identity_id: typing.Any = None + cls, + feature_state_model: FeatureStateModel, + identity_id: typing.Union[str, int] = None, ) -> "Flag": return Flag( enabled=feature_state_model.enabled, @@ -55,7 +57,7 @@ def from_feature_state_models( cls, feature_states: typing.List[FeatureStateModel], analytics_processor: AnalyticsProcessor, - identity_id: typing.Any = None, + identity_id: typing.Union[str, int] = None, defaults: typing.List[DefaultFlag] = None, ) -> "Flags": flags = { From 8ad218bc8bd3d2181cd5a31d149d7cbe12359dfd Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 26 Jan 2022 11:02:10 +0000 Subject: [PATCH 18/21] Update example app to use defaults --- example/app.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/example/app.py b/example/app.py index c2f6ade..d8b2b17 100644 --- a/example/app.py +++ b/example/app.py @@ -4,10 +4,20 @@ from flask import Flask, render_template from flagsmith import Flagsmith +from flagsmith.models import DefaultFlag app = Flask(__name__) -flagsmith = Flagsmith(environment_key=os.environ.get("FLAGSMITH_ENVIRONMENT_KEY")) +flagsmith = Flagsmith( + environment_key=os.environ.get("FLAGSMITH_ENVIRONMENT_KEY"), + defaults=[ + DefaultFlag( + enabled=True, + value=json.dumps({"colour": "#b8b8b8"}), + feature_name="secret_button", + ) + ], +) @app.route("/") From b2ee745249b6f92f46a1ff919a5686ec73ff8017 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 27 Jan 2022 14:40:40 +0000 Subject: [PATCH 19/21] Tidy up example app --- example/{.env => .env.template} | 0 example/app.py | 37 +++++++++++++++++++++++++-------- example/readme.md | 7 ++++--- example/templates/home.html | 19 ++++++++++++++++- 4 files changed, 50 insertions(+), 13 deletions(-) rename example/{.env => .env.template} (100%) diff --git a/example/.env b/example/.env.template similarity index 100% rename from example/.env rename to example/.env.template diff --git a/example/app.py b/example/app.py index d8b2b17..8d0db39 100644 --- a/example/app.py +++ b/example/app.py @@ -1,7 +1,7 @@ import json import os -from flask import Flask, render_template +from flask import Flask, render_template, request from flagsmith import Flagsmith from flagsmith.models import DefaultFlag @@ -11,8 +11,10 @@ flagsmith = Flagsmith( environment_key=os.environ.get("FLAGSMITH_ENVIRONMENT_KEY"), defaults=[ + # Set a default flag which will be used if the "secret_button" + # feature is not returned by the API DefaultFlag( - enabled=True, + enabled=False, value=json.dumps({"colour": "#b8b8b8"}), feature_name="secret_button", ) @@ -20,15 +22,32 @@ ) -@app.route("/") -def hello_world(): - flags = flagsmith.get_environment_flags() - identity_flags = flagsmith.get_identity_flags("identifier") +@app.route("/", methods=["GET", "POST"]) +def home(): + if request.args: + identifier = request.args["identifier"] - show_button = flags.is_feature_enabled( - "secret_button" - ) and identity_flags.is_feature_enabled("secret_button") + trait_key = request.args.get("trait-key") + trait_value = request.args.get("trait-value") + traits = {trait_key: trait_value} if trait_key else None + # Get the flags for an identity, including the provided trait which will be + # persisted to the API for future requests. + identity_flags = flagsmith.get_identity_flags( + identifier=identifier, traits=traits + ) + show_button = identity_flags.is_feature_enabled("secret_button") + button_data = json.loads(identity_flags.get_feature_value("secret_button")) + return render_template( + "home.html", + show_button=show_button, + button_colour=button_data["colour"], + identifier=identifier, + ) + + # Get the default flags for the current environment + flags = flagsmith.get_environment_flags() + show_button = flags.is_feature_enabled("secret_button") button_data = json.loads(flags.get_feature_value("secret_button")) return render_template( diff --git a/example/readme.md b/example/readme.md index 297086d..7ac5c16 100644 --- a/example/readme.md +++ b/example/readme.md @@ -11,11 +11,12 @@ need to go through the following steps: {"colour": "#ababab"} ``` -4. Update the .env file located in this directory with the environment key of one of the environments in flagsmith ( -This can be found on the 'settings' page accessed from the menu on the left under the chosen environment.) +4. Create a .env file from the template located in this directory with the environment key of one of the environments +in flagsmith (This can be found on the 'settings' page accessed from the menu on the left under the chosen environment.) 5. From a terminal window, export those environment variables (either manually or by using `export $(cat .env)`) 6. Run the app using `flask run` 7. Browse to http://localhost:5000 Now you can play around with the 'secret_button' feature in flagsmith, turn it on to show it and edit the colour in the -json value to edit the colour of the button. +json value to edit the colour of the button. You can also identify as a given user and then update the settings for the +secret button feature for that user in the flagsmith interface to see the affect that has too. diff --git a/example/templates/home.html b/example/templates/home.html index 8fbc127..4744cf0 100644 --- a/example/templates/home.html +++ b/example/templates/home.html @@ -1,8 +1,25 @@ + + + Flagsmith Example -

Hello, World.

+

Hello, {{ identifier or 'World' }}.

{% if show_button %} {% endif %} + +

+ +
+

Identify as a user

+
+ +

... with an optional user trait

+
+

+ + +
+ \ No newline at end of file From 4b7234d659e10c80593b01dcf08e96d6f125e1cf Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Mon, 31 Jan 2022 12:46:13 +0000 Subject: [PATCH 20/21] Tidy up type hint --- flagsmith/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagsmith/models.py b/flagsmith/models.py index 0b12260..76c0b0c 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -117,7 +117,7 @@ def get_feature_value(self, feature_name: str) -> typing.Any: """ return self.get_flag(feature_name).value - def get_flag(self, feature_name: str) -> typing.Optional[BaseFlag]: + def get_flag(self, feature_name: str) -> BaseFlag: """ Get a specific flag given the feature name. From bd7790c96989028438ccd3c1b74052cdfe208056 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 2 Feb 2022 10:13:34 +0000 Subject: [PATCH 21/21] Fix environment document url --- flagsmith/flagsmith.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 5d3243a..3b444e7 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -45,7 +45,7 @@ def __init__( self.environment_flags_url = f"{self.api_url}flags/" self.identities_url = f"{self.api_url}identities/" - self.environment_url = f"{self.api_url}environment/" + self.environment_url = f"{self.api_url}environment-document/" self._environment = None if enable_client_side_evaluation: