diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 66922b5..857ce3a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,8 @@ repos: hooks: - id: mypy args: [--strict] - additional_dependencies: [pydantic, pytest, pytest_mock] + additional_dependencies: + [pydantic, pytest, pytest_mock, types-requests, flagsmith-flag-engine, responses, types-pytz, sseclient-py] - repo: https://github.com/PyCQA/isort rev: 5.12.0 hooks: diff --git a/flagsmith/__init__.py b/flagsmith/__init__.py index 46571d5..4fbd5fc 100644 --- a/flagsmith/__init__.py +++ b/flagsmith/__init__.py @@ -1 +1,3 @@ -from .flagsmith import Flagsmith # noqa +from .flagsmith import Flagsmith + +__all__ = ("Flagsmith",) diff --git a/flagsmith/analytics.py b/flagsmith/analytics.py index 97b596e..dee1ed5 100644 --- a/flagsmith/analytics.py +++ b/flagsmith/analytics.py @@ -1,12 +1,13 @@ import json +import typing from datetime import datetime -from requests_futures.sessions import FuturesSession +from requests_futures.sessions import FuturesSession # type: ignore -ANALYTICS_ENDPOINT = "analytics/flags/" +ANALYTICS_ENDPOINT: typing.Final[str] = "analytics/flags/" # Used to control how often we send data(in seconds) -ANALYTICS_TIMER = 10 +ANALYTICS_TIMER: typing.Final[int] = 10 session = FuturesSession(max_workers=4) @@ -17,7 +18,9 @@ class AnalyticsProcessor: the Flagsmith SDK. Docs: https://docs.flagsmith.com/advanced-use/flag-analytics. """ - def __init__(self, environment_key: str, base_api_url: str, timeout: int = 3): + def __init__( + self, environment_key: str, base_api_url: str, timeout: typing.Optional[int] = 3 + ): """ Initialise the AnalyticsProcessor to handle sending analytics on flag usage to the Flagsmith API. @@ -30,10 +33,10 @@ def __init__(self, environment_key: str, base_api_url: str, timeout: int = 3): self.analytics_endpoint = base_api_url + ANALYTICS_ENDPOINT self.environment_key = environment_key self._last_flushed = datetime.now() - self.analytics_data = {} - self.timeout = timeout + self.analytics_data: typing.MutableMapping[str, typing.Any] = {} + self.timeout = timeout or 3 - def flush(self): + def flush(self) -> None: """ Sends all the collected data to the api asynchronously and resets the timer """ @@ -53,7 +56,7 @@ def flush(self): self.analytics_data.clear() self._last_flushed = datetime.now() - def track_feature(self, feature_name: str): + def track_feature(self, feature_name: str) -> None: self.analytics_data[feature_name] = self.analytics_data.get(feature_name, 0) + 1 if (datetime.now() - self._last_flushed).seconds > ANALYTICS_TIMER: self.flush() diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index a600182..3cd4c2b 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -20,13 +20,23 @@ from flagsmith.offline_handlers import BaseOfflineHandler from flagsmith.polling_manager import EnvironmentDataPollingManager from flagsmith.streaming_manager import EventStreamManager, StreamEvent -from flagsmith.utils.identities import generate_identities_data +from flagsmith.utils.identities import Identity, generate_identities_data logger = logging.getLogger(__name__) DEFAULT_API_URL = "https://edge.api.flagsmith.com/api/v1/" DEFAULT_REALTIME_API_URL = "https://realtime.flagsmith.com/" +JsonType = typing.Union[ + None, + int, + str, + bool, + typing.List["JsonType"], + typing.List[typing.Mapping[str, "JsonType"]], + typing.Dict[str, "JsonType"], +] + class Flagsmith: """A Flagsmith client. @@ -45,19 +55,21 @@ class Flagsmith: def __init__( self, - environment_key: str = None, - api_url: str = None, + environment_key: typing.Optional[str] = None, + api_url: typing.Optional[str] = None, realtime_api_url: typing.Optional[str] = None, - custom_headers: typing.Dict[str, typing.Any] = None, - request_timeout_seconds: int = None, + custom_headers: typing.Optional[typing.Dict[str, typing.Any]] = None, + request_timeout_seconds: typing.Optional[int] = None, enable_local_evaluation: bool = False, environment_refresh_interval_seconds: typing.Union[int, float] = 60, - retries: Retry = None, + retries: typing.Optional[Retry] = None, enable_analytics: bool = False, - default_flag_handler: typing.Callable[[str], DefaultFlag] = None, - proxies: typing.Dict[str, str] = None, + default_flag_handler: typing.Optional[ + typing.Callable[[str], DefaultFlag] + ] = None, + proxies: typing.Optional[typing.Dict[str, str]] = None, offline_mode: bool = False, - offline_handler: BaseOfflineHandler = None, + offline_handler: typing.Optional[BaseOfflineHandler] = None, enable_realtime_updates: bool = False, ): """ @@ -94,8 +106,8 @@ def __init__( self.offline_handler = offline_handler self.default_flag_handler = default_flag_handler self.enable_realtime_updates = enable_realtime_updates - self._analytics_processor = None - self._environment = None + self._analytics_processor: typing.Optional[AnalyticsProcessor] = None + self._environment: typing.Optional[EnvironmentModel] = None self._identity_overrides_by_identifier: typing.Dict[str, IdentityModel] = {} # argument validation @@ -159,6 +171,9 @@ def __init__( def _initialise_local_evaluation(self) -> None: if self.enable_realtime_updates: self.update_environment() + if not self._environment: + raise ValueError("Unable to get environment from API key") + stream_url = f"{self.realtime_api_url}sse/environments/{self._environment.api_key}/stream" self.event_stream_thread = EventStreamManager( @@ -196,6 +211,10 @@ def handle_stream_event(self, event: StreamEvent) -> None: if stream_updated_at.tzinfo is None: stream_updated_at = pytz.utc.localize(stream_updated_at) + if not self._environment: + raise ValueError( + "Unable to access environment. Environment should not be null" + ) environment_updated_at = self._environment.updated_at if environment_updated_at.tzinfo is None: environment_updated_at = pytz.utc.localize(environment_updated_at) @@ -214,7 +233,9 @@ def get_environment_flags(self) -> Flags: return self._get_environment_flags_from_api() def get_identity_flags( - self, identifier: str, traits: typing.Dict[str, typing.Any] = None + self, + identifier: str, + traits: typing.Optional[typing.Mapping[str, TraitValue]] = None, ) -> Flags: """ Get all the flags for the current environment for a given identity. Will also @@ -233,7 +254,9 @@ def get_identity_flags( return self._get_identity_flags_from_api(identifier, traits) def get_identity_segments( - self, identifier: str, traits: typing.Dict[str, typing.Any] = None + self, + identifier: str, + traits: typing.Optional[typing.Mapping[str, TraitValue]] = None, ) -> typing.List[Segment]: """ Get a list of segments that the given identity is in. @@ -255,7 +278,7 @@ def get_identity_segments( segment_models = get_identity_segments(self._environment, identity_model) return [Segment(id=sm.id, name=sm.name) for sm in segment_models] - def update_environment(self): + def update_environment(self) -> None: self._environment = self._get_environment_from_api() self._update_overrides() @@ -272,6 +295,8 @@ def _get_environment_from_api(self) -> EnvironmentModel: return EnvironmentModel.model_validate(environment_data) def _get_environment_flags_from_document(self) -> Flags: + if self._environment is None: + raise TypeError("No environment present") return Flags.from_feature_state_models( feature_states=engine.get_environment_feature_states(self._environment), analytics_processor=self._analytics_processor, @@ -279,9 +304,11 @@ def _get_environment_flags_from_document(self) -> Flags: ) def _get_identity_flags_from_document( - self, identifier: str, traits: typing.Dict[str, typing.Any] + self, identifier: str, traits: typing.Mapping[str, TraitValue] ) -> Flags: identity_model = self._get_identity_model(identifier, **traits) + if self._environment is None: + raise TypeError("No environment present") feature_states = engine.get_identity_feature_states( self._environment, identity_model ) @@ -294,11 +321,11 @@ def _get_identity_flags_from_document( def _get_environment_flags_from_api(self) -> Flags: try: - api_flags = self._get_json_response( - url=self.environment_flags_url, method="GET" - ) + json_response: typing.List[ + typing.Mapping[str, JsonType] + ] = self._get_json_response(url=self.environment_flags_url, method="GET") return Flags.from_api_flags( - api_flags=api_flags, + api_flags=json_response, analytics_processor=self._analytics_processor, default_flag_handler=self.default_flag_handler, ) @@ -310,11 +337,13 @@ def _get_environment_flags_from_api(self) -> Flags: raise def _get_identity_flags_from_api( - self, identifier: str, traits: typing.Dict[str, typing.Any] + self, identifier: str, traits: typing.Mapping[str, typing.Any] ) -> Flags: try: data = generate_identities_data(identifier, traits) - json_response = self._get_json_response( + json_response: typing.Dict[ + str, typing.List[typing.Dict[str, JsonType]] + ] = self._get_json_response( url=self.identities_url, method="POST", body=data ) return Flags.from_api_flags( @@ -329,7 +358,14 @@ def _get_identity_flags_from_api( return Flags(default_flag_handler=self.default_flag_handler) raise - def _get_json_response(self, url: str, method: str, body: dict = None): + def _get_json_response( + self, + url: str, + method: str, + body: typing.Optional[ + typing.Union[Identity, typing.Dict[str, JsonType]] + ] = None, + ) -> typing.Any: try: request_method = getattr(self.session, method.lower()) response = request_method( @@ -371,7 +407,7 @@ def _get_identity_model( identity_traits=trait_models, ) - def __del__(self): + def __del__(self) -> None: 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 index 41d0037..37bab3f 100644 --- a/flagsmith/models.py +++ b/flagsmith/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import typing from dataclasses import dataclass, field @@ -13,7 +15,7 @@ class BaseFlag: value: typing.Union[str, int, float, bool, None] -@dataclass +@dataclass() class DefaultFlag(BaseFlag): is_default: bool = field(default=True) @@ -28,8 +30,8 @@ class Flag(BaseFlag): def from_feature_state_model( cls, feature_state_model: FeatureStateModel, - identity_id: typing.Union[str, int] = None, - ) -> "Flag": + identity_id: typing.Optional[typing.Union[str, int]] = None, + ) -> Flag: return Flag( enabled=feature_state_model.enabled, value=feature_state_model.get_value(identity_id=identity_id), @@ -38,7 +40,7 @@ def from_feature_state_model( ) @classmethod - def from_api_flag(cls, flag_data: dict) -> "Flag": + def from_api_flag(cls, flag_data: typing.Mapping[str, typing.Any]) -> Flag: return Flag( enabled=flag_data["enabled"], value=flag_data["feature_state_value"], @@ -50,17 +52,17 @@ def from_api_flag(cls, flag_data: dict) -> "Flag": @dataclass class Flags: flags: typing.Dict[str, Flag] = field(default_factory=dict) - default_flag_handler: typing.Callable[[str], DefaultFlag] = None - _analytics_processor: AnalyticsProcessor = None + default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]] = None + _analytics_processor: typing.Optional[AnalyticsProcessor] = None @classmethod def from_feature_state_models( cls, - feature_states: typing.List[FeatureStateModel], - analytics_processor: AnalyticsProcessor, - default_flag_handler: typing.Callable, - identity_id: typing.Union[str, int] = None, - ) -> "Flags": + feature_states: typing.Sequence[FeatureStateModel], + analytics_processor: typing.Optional[AnalyticsProcessor], + default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]], + identity_id: typing.Optional[typing.Union[str, int]] = None, + ) -> Flags: flags = { feature_state.feature.name: Flag.from_feature_state_model( feature_state, identity_id=identity_id @@ -77,10 +79,10 @@ def from_feature_state_models( @classmethod def from_api_flags( cls, - api_flags: typing.List[dict], - analytics_processor: AnalyticsProcessor, - default_flag_handler: typing.Callable, - ) -> "Flags": + api_flags: typing.Sequence[typing.Mapping[str, typing.Any]], + analytics_processor: typing.Optional[AnalyticsProcessor], + default_flag_handler: typing.Optional[typing.Callable[[str], DefaultFlag]], + ) -> Flags: flags = { flag_data["feature"]["name"]: Flag.from_api_flag(flag_data) for flag_data in api_flags @@ -120,12 +122,12 @@ def get_feature_value(self, feature_name: str) -> typing.Any: """ return self.get_flag(feature_name).value - def get_flag(self, feature_name: str) -> BaseFlag: + def get_flag(self, feature_name: str) -> typing.Union[DefaultFlag, Flag]: """ Get a specific flag given the feature name. :param feature_name: the name of the feature to retrieve the flag for. - :return: BaseFlag object. + :return: DefaultFlag | Flag object. :raises FlagsmithClientError: if feature doesn't exist """ try: diff --git a/flagsmith/polling_manager.py b/flagsmith/polling_manager.py index e922ea8..85b8486 100644 --- a/flagsmith/polling_manager.py +++ b/flagsmith/polling_manager.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import threading import time @@ -16,10 +18,10 @@ class EnvironmentDataPollingManager(threading.Thread): def __init__( self, - *args, - main: "Flagsmith", + *args: typing.Any, + main: Flagsmith, refresh_interval_seconds: typing.Union[int, float] = 10, - **kwargs + **kwargs: typing.Any, ): super(EnvironmentDataPollingManager, self).__init__(*args, **kwargs) self._stop_event = threading.Event() @@ -37,5 +39,5 @@ def run(self) -> None: def stop(self) -> None: self._stop_event.set() - def __del__(self): + def __del__(self) -> None: self._stop_event.set() diff --git a/flagsmith/streaming_manager.py b/flagsmith/streaming_manager.py index 64ac6a7..3fc6817 100644 --- a/flagsmith/streaming_manager.py +++ b/flagsmith/streaming_manager.py @@ -1,5 +1,6 @@ import logging import threading +import typing from typing import Callable, Generator, Optional, Protocol, cast import requests @@ -17,11 +18,11 @@ class StreamEvent(Protocol): class EventStreamManager(threading.Thread): def __init__( self, - *args, + *args: typing.Any, stream_url: str, on_event: Callable[[StreamEvent], None], request_timeout_seconds: Optional[int] = None, - **kwargs + **kwargs: typing.Any ) -> None: super().__init__(*args, **kwargs) self._stop_event = threading.Event() diff --git a/flagsmith/utils/identities.py b/flagsmith/utils/identities.py index 9c82333..79d8875 100644 --- a/flagsmith/utils/identities.py +++ b/flagsmith/utils/identities.py @@ -1,5 +1,19 @@ -def generate_identities_data(identifier: str, traits: dict = None): +import typing + +from flag_engine.identities.traits.types import TraitValue + +Identity = typing.TypedDict( + "Identity", + {"identifier": str, "traits": typing.List[typing.Mapping[str, TraitValue]]}, +) + + +def generate_identities_data( + identifier: str, traits: typing.Optional[typing.Mapping[str, TraitValue]] = None +) -> Identity: 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()] + if traits + else [], } diff --git a/poetry.lock b/poetry.lock index 587401e..c8990ce 100644 --- a/poetry.lock +++ b/poetry.lock @@ -206,6 +206,73 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.4.3" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8580b827d4746d47294c0e0b92854c85a92c2227927433998f0d3320ae8a71b6"}, + {file = "coverage-7.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:718187eeb9849fc6cc23e0d9b092bc2348821c5e1a901c9f8975df0bc785bfd4"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:767b35c3a246bcb55b8044fd3a43b8cd553dd1f9f2c1eeb87a302b1f8daa0524"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae7f19afe0cce50039e2c782bff379c7e347cba335429678450b8fe81c4ef96d"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba3a8aaed13770e970b3df46980cb068d1c24af1a1968b7818b69af8c4347efb"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ee866acc0861caebb4f2ab79f0b94dbfbdbfadc19f82e6e9c93930f74e11d7a0"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:506edb1dd49e13a2d4cac6a5173317b82a23c9d6e8df63efb4f0380de0fbccbc"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd6545d97c98a192c5ac995d21c894b581f1fd14cf389be90724d21808b657e2"}, + {file = "coverage-7.4.3-cp310-cp310-win32.whl", hash = "sha256:f6a09b360d67e589236a44f0c39218a8efba2593b6abdccc300a8862cffc2f94"}, + {file = "coverage-7.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:18d90523ce7553dd0b7e23cbb28865db23cddfd683a38fb224115f7826de78d0"}, + {file = "coverage-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbbe5e739d45a52f3200a771c6d2c7acf89eb2524890a4a3aa1a7fa0695d2a47"}, + {file = "coverage-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:489763b2d037b164846ebac0cbd368b8a4ca56385c4090807ff9fad817de4113"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:451f433ad901b3bb00184d83fd83d135fb682d780b38af7944c9faeecb1e0bfe"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcc66e222cf4c719fe7722a403888b1f5e1682d1679bd780e2b26c18bb648cdc"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ec74cfef2d985e145baae90d9b1b32f85e1741b04cd967aaf9cfa84c1334f3"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:abbbd8093c5229c72d4c2926afaee0e6e3140de69d5dcd918b2921f2f0c8baba"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:35eb581efdacf7b7422af677b92170da4ef34500467381e805944a3201df2079"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8249b1c7334be8f8c3abcaaa996e1e4927b0e5a23b65f5bf6cfe3180d8ca7840"}, + {file = "coverage-7.4.3-cp311-cp311-win32.whl", hash = "sha256:cf30900aa1ba595312ae41978b95e256e419d8a823af79ce670835409fc02ad3"}, + {file = "coverage-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:18c7320695c949de11a351742ee001849912fd57e62a706d83dfc1581897fa2e"}, + {file = "coverage-7.4.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b51bfc348925e92a9bd9b2e48dad13431b57011fd1038f08316e6bf1df107d10"}, + {file = "coverage-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cdecaedea1ea9e033d8adf6a0ab11107b49571bbb9737175444cea6eb72328"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b2eccb883368f9e972e216c7b4c7c06cabda925b5f06dde0650281cb7666a30"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c00cdc8fa4e50e1cc1f941a7f2e3e0f26cb2a1233c9696f26963ff58445bac7"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9a4a8dd3dcf4cbd3165737358e4d7dfbd9d59902ad11e3b15eebb6393b0446e"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:062b0a75d9261e2f9c6d071753f7eef0fc9caf3a2c82d36d76667ba7b6470003"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ebe7c9e67a2d15fa97b77ea6571ce5e1e1f6b0db71d1d5e96f8d2bf134303c1d"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c0a120238dd71c68484f02562f6d446d736adcc6ca0993712289b102705a9a3a"}, + {file = "coverage-7.4.3-cp312-cp312-win32.whl", hash = "sha256:37389611ba54fd6d278fde86eb2c013c8e50232e38f5c68235d09d0a3f8aa352"}, + {file = "coverage-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:d25b937a5d9ffa857d41be042b4238dd61db888533b53bc76dc082cb5a15e914"}, + {file = "coverage-7.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:28ca2098939eabab044ad68850aac8f8db6bf0b29bc7f2887d05889b17346454"}, + {file = "coverage-7.4.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:280459f0a03cecbe8800786cdc23067a8fc64c0bd51dc614008d9c36e1659d7e"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c0cdedd3500e0511eac1517bf560149764b7d8e65cb800d8bf1c63ebf39edd2"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a9babb9466fe1da12417a4aed923e90124a534736de6201794a3aea9d98484e"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dec9de46a33cf2dd87a5254af095a409ea3bf952d85ad339751e7de6d962cde6"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:16bae383a9cc5abab9bb05c10a3e5a52e0a788325dc9ba8499e821885928968c"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2c854ce44e1ee31bda4e318af1dbcfc929026d12c5ed030095ad98197eeeaed0"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ce8c50520f57ec57aa21a63ea4f325c7b657386b3f02ccaedeccf9ebe27686e1"}, + {file = "coverage-7.4.3-cp38-cp38-win32.whl", hash = "sha256:708a3369dcf055c00ddeeaa2b20f0dd1ce664eeabde6623e516c5228b753654f"}, + {file = "coverage-7.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:1bf25fbca0c8d121a3e92a2a0555c7e5bc981aee5c3fdaf4bb7809f410f696b9"}, + {file = "coverage-7.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b253094dbe1b431d3a4ac2f053b6d7ede2664ac559705a704f621742e034f1f"}, + {file = "coverage-7.4.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:77fbfc5720cceac9c200054b9fab50cb2a7d79660609200ab83f5db96162d20c"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6679060424faa9c11808598504c3ab472de4531c571ab2befa32f4971835788e"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4af154d617c875b52651dd8dd17a31270c495082f3d55f6128e7629658d63765"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8640f1fde5e1b8e3439fe482cdc2b0bb6c329f4bb161927c28d2e8879c6029ee"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:69b9f6f66c0af29642e73a520b6fed25ff9fd69a25975ebe6acb297234eda501"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0842571634f39016a6c03e9d4aba502be652a6e4455fadb73cd3a3a49173e38f"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a78ed23b08e8ab524551f52953a8a05d61c3a760781762aac49f8de6eede8c45"}, + {file = "coverage-7.4.3-cp39-cp39-win32.whl", hash = "sha256:c0524de3ff096e15fcbfe8f056fdb4ea0bf497d584454f344d59fce069d3e6e9"}, + {file = "coverage-7.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:0209a6369ccce576b43bb227dc8322d8ef9e323d089c6f3f26a597b09cb4d2aa"}, + {file = "coverage-7.4.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:7cbde573904625509a3f37b6fecea974e363460b556a627c60dc2f47e2fffa51"}, + {file = "coverage-7.4.3.tar.gz", hash = "sha256:276f6077a5c61447a48d133ed13e759c09e62aff0dc84274a68dc18660104d52"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + [[package]] name = "distlib" version = "0.3.8" @@ -494,13 +561,13 @@ files = [ [[package]] name = "pydantic" -version = "2.6.4" +version = "2.6.3" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"}, - {file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"}, + {file = "pydantic-2.6.3-py3-none-any.whl", hash = "sha256:72c6034df47f46ccdf81869fddb81aade68056003900a8724a4f160700016a2a"}, + {file = "pydantic-2.6.3.tar.gz", hash = "sha256:e07805c4c7f5c6826e33a1d4c9d47950d7eaf34868e2690f8594d2e30241f11f"}, ] [package.dependencies] @@ -650,6 +717,24 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + [[package]] name = "pytest-mock" version = "3.12.0" @@ -777,22 +862,22 @@ dev = ["black (>=22.3.0)", "build (>=0.7.0)", "isort (>=5.11.4)", "pyflakes (>=2 [[package]] name = "responses" -version = "0.17.0" +version = "0.24.1" description = "A utility library for mocking out the `requests` Python library." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.8" files = [ - {file = "responses-0.17.0-py2.py3-none-any.whl", hash = "sha256:e4fc472fb7374fb8f84fcefa51c515ca4351f198852b4eb7fc88223780b472ea"}, - {file = "responses-0.17.0.tar.gz", hash = "sha256:ec675e080d06bf8d1fb5e5a68a1e5cd0df46b09c78230315f650af5e4036bec7"}, + {file = "responses-0.24.1-py3-none-any.whl", hash = "sha256:a2b43f4c08bfb9c9bd242568328c65a34b318741d3fab884ac843c5ceeb543f9"}, + {file = "responses-0.24.1.tar.gz", hash = "sha256:b127c6ca3f8df0eb9cc82fd93109a3007a86acb24871834c47b77765152ecf8c"}, ] [package.dependencies] -requests = ">=2.0" -six = "*" -urllib3 = ">=1.25.10" +pyyaml = "*" +requests = ">=2.30.0,<3.0" +urllib3 = ">=1.25.10,<3.0" [package.extras] -tests = ["coverage (>=3.7.1,<6.0.0)", "flake8", "mypy", "pytest (>=4.6)", "pytest (>=4.6,<5.0)", "pytest-cov", "pytest-localserver", "types-mock", "types-requests", "types-six"] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"] [[package]] name = "semver" @@ -821,17 +906,6 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - [[package]] name = "sseclient-py" version = "1.8.0" @@ -854,6 +928,31 @@ files = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] +[[package]] +name = "types-pytz" +version = "2024.1.0.20240203" +description = "Typing stubs for pytz" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-pytz-2024.1.0.20240203.tar.gz", hash = "sha256:c93751ee20dfc6e054a0148f8f5227b9a00b79c90a4d3c9f464711a73179c89e"}, + {file = "types_pytz-2024.1.0.20240203-py3-none-any.whl", hash = "sha256:9679eef0365db3af91ef7722c199dbb75ee5c1b67e3c4dd7bfbeb1b8a71c21a3"}, +] + +[[package]] +name = "types-requests" +version = "2.31.0.20240218" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-requests-2.31.0.20240218.tar.gz", hash = "sha256:f1721dba8385958f504a5386240b92de4734e047a08a40751c1654d1ac3349c5"}, + {file = "types_requests-2.31.0.20240218-py3-none-any.whl", hash = "sha256:a82807ec6ddce8f00fe0e949da6d6bc1fbf1715420218a9640d695f70a9e5a9b"}, +] + +[package.dependencies] +urllib3 = ">=2" + [[package]] name = "typing-extensions" version = "4.10.0" @@ -905,4 +1004,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<4" -content-hash = "17bb40bb6fbdd32e257c5503306d4d040b531fd1670c8fcfbafede74fdc9d4a5" +content-hash = "98662db30ac17a633b8b5a92816f464847c1c48d68f57842863af90444605b25" diff --git a/pyproject.toml b/pyproject.toml index b862f07..a6558c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,13 +25,18 @@ pytest = "^7.4.0" pytest-mock = "^3.6.1" black = "^23.3.0" pre-commit = "^2.17.0" -responses = "^0.17.0" +responses = "^0.24.1" flake8 = "^6.1.0" isort = "^5.12.0" mypy = "^1.7.1" +types-requests = "^2.31.0.10" +pytest-cov = "^4.1.0" +types-pytz = "^2024.1.0.20240203" [tool.mypy] plugins = ["pydantic.mypy"] +exclude = ["example/*"] + [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/conftest.py b/tests/conftest.py index d13c761..0d28bf2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import os import random import string +import typing from typing import Generator import pytest @@ -16,37 +17,35 @@ @pytest.fixture() -def analytics_processor(): +def analytics_processor() -> AnalyticsProcessor: return AnalyticsProcessor( environment_key="test_key", base_api_url="http://test_url" ) @pytest.fixture(scope="session") -def api_key(): +def api_key() -> str: return "".join(random.sample(string.ascii_letters, 20)) @pytest.fixture(scope="session") -def server_api_key(): +def server_api_key() -> str: return "ser.%s" % "".join(random.sample(string.ascii_letters, 20)) @pytest.fixture() -def flagsmith(api_key): +def flagsmith(api_key: str) -> Flagsmith: return Flagsmith(environment_key=api_key) @pytest.fixture() -def environment_json(): +def environment_json() -> typing.Generator[str, None, None]: with open(os.path.join(DATA_DIR, "environment.json"), "rt") as f: yield f.read() @pytest.fixture() -def requests_session_response_ok( - mocker: Generator[MockerFixture, None, None], environment_json: str -) -> None: +def requests_session_response_ok(mocker: MockerFixture, environment_json: str) -> None: mock_session = mocker.MagicMock() mocker.patch("flagsmith.flagsmith.requests.Session", return_value=mock_session) @@ -76,13 +75,13 @@ def environment_model(environment_json: str) -> EnvironmentModel: @pytest.fixture() -def flags_json(): +def flags_json() -> typing.Generator[str, None, None]: with open(os.path.join(DATA_DIR, "flags.json"), "rt") as f: yield f.read() @pytest.fixture() -def identities_json(): +def identities_json() -> typing.Generator[str, None, None]: with open(os.path.join(DATA_DIR, "identities.json"), "rt") as f: yield f.read() diff --git a/tests/test_analytics.py b/tests/test_analytics.py index 60126b4..7a2fd65 100644 --- a/tests/test_analytics.py +++ b/tests/test_analytics.py @@ -2,10 +2,12 @@ from datetime import datetime, timedelta from unittest import mock -from flagsmith.analytics import ANALYTICS_TIMER +from flagsmith.analytics import ANALYTICS_TIMER, AnalyticsProcessor -def test_analytics_processor_track_feature_updates_analytics_data(analytics_processor): +def test_analytics_processor_track_feature_updates_analytics_data( + analytics_processor: AnalyticsProcessor, +) -> None: # When analytics_processor.track_feature("my_feature") assert analytics_processor.analytics_data["my_feature"] == 1 @@ -14,15 +16,17 @@ def test_analytics_processor_track_feature_updates_analytics_data(analytics_proc assert analytics_processor.analytics_data["my_feature"] == 2 -def test_analytics_processor_flush_clears_analytics_data(analytics_processor): +def test_analytics_processor_flush_clears_analytics_data( + analytics_processor: AnalyticsProcessor, +) -> None: analytics_processor.track_feature("my_feature") analytics_processor.flush() assert analytics_processor.analytics_data == {} def test_analytics_processor_flush_post_request_data_match_ananlytics_data( - analytics_processor, -): + analytics_processor: AnalyticsProcessor, +) -> None: # Given with mock.patch("flagsmith.analytics.session") as session: # When @@ -36,8 +40,8 @@ def test_analytics_processor_flush_post_request_data_match_ananlytics_data( def test_analytics_processor_flush_early_exit_if_analytics_data_is_empty( - analytics_processor, -): + analytics_processor: AnalyticsProcessor, +) -> None: with mock.patch("flagsmith.analytics.session") as session: analytics_processor.flush() @@ -46,8 +50,8 @@ def test_analytics_processor_flush_early_exit_if_analytics_data_is_empty( def test_analytics_processor_calling_track_feature_calls_flush_when_timer_runs_out( - analytics_processor, -): + analytics_processor: AnalyticsProcessor, +) -> None: # Given with mock.patch("flagsmith.analytics.datetime") as mocked_datetime, mock.patch( "flagsmith.analytics.session" diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index 9d0d23c..f6afdbc 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -6,19 +6,19 @@ import pytest import requests import responses +from flag_engine.environments.models import EnvironmentModel from flag_engine.features.models import FeatureModel, FeatureStateModel +from pytest_mock import MockerFixture from flagsmith import Flagsmith from flagsmith.exceptions import FlagsmithAPIError from flagsmith.models import DefaultFlag, Flags from flagsmith.offline_handlers import BaseOfflineHandler -if typing.TYPE_CHECKING: - from flag_engine.environments.models import EnvironmentModel - from pytest_mock import MockerFixture - -def test_flagsmith_starts_polling_manager_on_init_if_enabled(mocker, server_api_key): +def test_flagsmith_starts_polling_manager_on_init_if_enabled( + mocker: MockerFixture, server_api_key: str +) -> None: # Given mock_polling_manager = mocker.MagicMock() mocker.patch( @@ -35,8 +35,8 @@ def test_flagsmith_starts_polling_manager_on_init_if_enabled(mocker, server_api_ @responses.activate() def test_update_environment_sets_environment( - flagsmith, environment_json, environment_model -): + flagsmith: Flagsmith, environment_json: str, environment_model: EnvironmentModel +) -> None: # Given responses.add(method="GET", url=flagsmith.environment_url, body=environment_json) assert flagsmith._environment is None @@ -51,8 +51,8 @@ def test_update_environment_sets_environment( @responses.activate() def test_get_environment_flags_calls_api_when_no_local_environment( - api_key, flagsmith, flags_json -): + api_key: str, flagsmith: Flagsmith, flags_json: str +) -> None: # Given responses.add(method="GET", url=flagsmith.environment_flags_url, body=flags_json) @@ -71,8 +71,8 @@ def test_get_environment_flags_calls_api_when_no_local_environment( @responses.activate() def test_get_environment_flags_uses_local_environment_when_available( - flagsmith, environment_model -): + flagsmith: Flagsmith, environment_model: EnvironmentModel +) -> None: # Given flagsmith._environment = environment_model flagsmith.enable_local_evaluation = True @@ -90,8 +90,8 @@ def test_get_environment_flags_uses_local_environment_when_available( @responses.activate() def test_get_identity_flags_calls_api_when_no_local_environment_no_traits( - flagsmith, identities_json -): + flagsmith: Flagsmith, identities_json: str +) -> None: # Given responses.add(method="POST", url=flagsmith.identities_url, body=identities_json) identifier = "identifier" @@ -100,9 +100,11 @@ def test_get_identity_flags_calls_api_when_no_local_environment_no_traits( identity_flags = flagsmith.get_identity_flags(identifier=identifier).all_flags() # Then - assert responses.calls[0].request.body.decode() == json.dumps( - {"identifier": identifier, "traits": []} - ) + body = responses.calls[0].request.body + if isinstance(body, bytes): + # Decode 'body' from bytes to string if it is in bytes format. + body = body.decode() + assert body == json.dumps({"identifier": identifier, "traits": []}) # Taken from hard coded values in tests/data/identities.json assert identity_flags[0].enabled is True @@ -112,8 +114,8 @@ 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_with_traits( - flagsmith, identities_json -): + flagsmith: Flagsmith, identities_json: str +) -> None: # Given responses.add(method="POST", url=flagsmith.identities_url, body=identities_json) identifier = "identifier" @@ -123,7 +125,11 @@ def test_get_identity_flags_calls_api_when_no_local_environment_with_traits( identity_flags = flagsmith.get_identity_flags(identifier=identifier, traits=traits) # Then - assert responses.calls[0].request.body.decode() == json.dumps( + body = responses.calls[0].request.body + if isinstance(body, bytes): + # Decode 'body' from bytes to string if it is in bytes format. + body = body.decode() + assert body == json.dumps( { "identifier": identifier, "traits": [{"trait_key": k, "trait_value": v} for k, v in traits.items()], @@ -138,8 +144,8 @@ def test_get_identity_flags_calls_api_when_no_local_environment_with_traits( @responses.activate() def test_get_identity_flags_uses_local_environment_when_available( - flagsmith, environment_model, mocker -): + flagsmith: Flagsmith, environment_model: EnvironmentModel, mocker: MockerFixture +) -> None: # Given flagsmith._environment = environment_model flagsmith.enable_local_evaluation = True @@ -163,7 +169,9 @@ def test_get_identity_flags_uses_local_environment_when_available( assert identity_flags[0].value == feature_state.get_value() -def test_request_connection_error_raises_flagsmith_api_error(mocker, api_key): +def test_request_connection_error_raises_flagsmith_api_error( + mocker: MockerFixture, api_key: str +) -> None: """ 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 @@ -188,7 +196,7 @@ def test_request_connection_error_raises_flagsmith_api_error(mocker, api_key): @responses.activate() -def test_non_200_response_raises_flagsmith_api_error(flagsmith): +def test_non_200_response_raises_flagsmith_api_error(flagsmith: Flagsmith) -> None: # Given responses.add(url=flagsmith.environment_flags_url, method="GET", status=400) @@ -201,7 +209,7 @@ def test_non_200_response_raises_flagsmith_api_error(flagsmith): @responses.activate() -def test_default_flag_is_used_when_no_environment_flags_returned(api_key): +def test_default_flag_is_used_when_no_environment_flags_returned(api_key: str) -> None: # Given feature_name = "some_feature" @@ -232,7 +240,9 @@ def default_flag_handler(feature_name: str) -> DefaultFlag: @responses.activate() -def test_default_flag_is_not_used_when_environment_flags_returned(api_key, flags_json): +def test_default_flag_is_not_used_when_environment_flags_returned( + api_key: str, flags_json: str +) -> None: # Given feature_name = "some_feature" @@ -261,7 +271,7 @@ def default_flag_handler(feature_name: str) -> DefaultFlag: @responses.activate() -def test_default_flag_is_used_when_no_identity_flags_returned(api_key): +def test_default_flag_is_used_when_no_identity_flags_returned(api_key: str) -> None: # Given feature_name = "some_feature" @@ -276,7 +286,10 @@ def default_flag_handler(feature_name: str) -> DefaultFlag: ) # and we mock the API to return an empty list of flags - response_data = {"flags": [], "traits": []} + response_data: typing.Mapping[str, typing.Sequence[typing.Any]] = { + "flags": [], + "traits": [], + } responses.add( url=flagsmith.identities_url, method="POST", body=json.dumps(response_data) ) @@ -294,8 +307,8 @@ def default_flag_handler(feature_name: str) -> DefaultFlag: @responses.activate() def test_default_flag_is_not_used_when_identity_flags_returned( - api_key, identities_json -): + api_key: str, identities_json: str +) -> None: # Given feature_name = "some_feature" @@ -323,7 +336,9 @@ def default_flag_handler(feature_name: str) -> DefaultFlag: assert flag.value == "some-value" # hard coded value in tests/data/identities.json -def test_default_flags_are_used_if_api_error_and_default_flag_handler_given(mocker): +def test_default_flags_are_used_if_api_error_and_default_flag_handler_given( + mocker: MockerFixture, +) -> None: # Given # a default flag and associated handler default_flag = DefaultFlag(True, "some-default-value") @@ -347,7 +362,9 @@ def default_flag_handler(feature_name: str) -> DefaultFlag: assert flags.get_flag("some-feature") == default_flag -def test_get_identity_segments_no_traits(local_eval_flagsmith, environment_model): +def test_get_identity_segments_no_traits( + local_eval_flagsmith: Flagsmith, environment_model: EnvironmentModel +) -> None: # Given identifier = "identifier" @@ -359,8 +376,8 @@ def test_get_identity_segments_no_traits(local_eval_flagsmith, environment_model def test_get_identity_segments_with_valid_trait( - local_eval_flagsmith, environment_model -): + local_eval_flagsmith: Flagsmith, environment_model: EnvironmentModel +) -> None: # Given identifier = "identifier" traits = {"foo": "bar"} # obtained from data/environment.json @@ -373,12 +390,12 @@ def test_get_identity_segments_with_valid_trait( assert segments[0].name == "Test segment" # obtained from data/environment.json -def test_local_evaluation_requires_server_key(): +def test_local_evaluation_requires_server_key() -> None: with pytest.raises(ValueError): Flagsmith(environment_key="not-a-server-key", enable_local_evaluation=True) -def test_initialise_flagsmith_with_proxies(): +def test_initialise_flagsmith_with_proxies() -> None: # Given proxies = {"https": "https://my.proxy.com/proxy-me"} @@ -389,10 +406,10 @@ def test_initialise_flagsmith_with_proxies(): assert flagsmith.session.proxies == proxies -def test_offline_mode(environment_model: "EnvironmentModel") -> None: +def test_offline_mode(environment_model: EnvironmentModel) -> None: # Given class DummyOfflineHandler(BaseOfflineHandler): - def get_environment(self) -> "EnvironmentModel": + def get_environment(self) -> EnvironmentModel: return environment_model # When @@ -409,7 +426,7 @@ def get_environment(self) -> "EnvironmentModel": @responses.activate() def test_flagsmith_uses_offline_handler_if_set_and_no_api_response( - mocker: "MockerFixture", environment_model: "EnvironmentModel" + mocker: MockerFixture, environment_model: EnvironmentModel ) -> None: # Given api_url = "http://some.flagsmith.com/api/v1/" @@ -439,7 +456,7 @@ def test_flagsmith_uses_offline_handler_if_set_and_no_api_response( assert identity_flags.get_feature_value("some_feature") == "some-value" -def test_cannot_use_offline_mode_without_offline_handler(): +def test_cannot_use_offline_mode_without_offline_handler() -> None: with pytest.raises(ValueError) as e: # When Flagsmith(offline_mode=True, offline_handler=None) @@ -451,7 +468,7 @@ def test_cannot_use_offline_mode_without_offline_handler(): ) -def test_cannot_use_default_handler_and_offline_handler(mocker): +def test_cannot_use_default_handler_and_offline_handler(mocker: MockerFixture) -> None: # When with pytest.raises(ValueError) as e: Flagsmith( @@ -468,7 +485,7 @@ def test_cannot_use_default_handler_and_offline_handler(mocker): ) -def test_cannot_create_flagsmith_client_in_remote_evaluation_without_api_key(): +def test_cannot_create_flagsmith_client_in_remote_evaluation_without_api_key() -> None: # When with pytest.raises(ValueError) as e: Flagsmith() diff --git a/tests/test_offline_handlers.py b/tests/test_offline_handlers.py index 862f173..b3cc597 100644 --- a/tests/test_offline_handlers.py +++ b/tests/test_offline_handlers.py @@ -5,7 +5,7 @@ from flagsmith.offline_handlers import LocalFileHandler -def test_local_file_handler(environment_json): +def test_local_file_handler(environment_json: str) -> None: with patch("builtins.open", mock_open(read_data=environment_json)) as mock_file: # Given environment_document_file_path = "/some/path/environment.json" diff --git a/tests/test_polling_manager.py b/tests/test_polling_manager.py index 6cfcffd..c30df6f 100644 --- a/tests/test_polling_manager.py +++ b/tests/test_polling_manager.py @@ -2,12 +2,13 @@ from unittest import mock import requests +from pytest_mock import MockerFixture from flagsmith import Flagsmith from flagsmith.polling_manager import EnvironmentDataPollingManager -def test_polling_manager_calls_update_environment_on_start(): +def test_polling_manager_calls_update_environment_on_start() -> None: # Given flagsmith = mock.MagicMock() polling_manager = EnvironmentDataPollingManager( @@ -22,7 +23,7 @@ def test_polling_manager_calls_update_environment_on_start(): polling_manager.stop() -def test_polling_manager_calls_update_environment_on_each_refresh(): +def test_polling_manager_calls_update_environment_on_each_refresh() -> None: # Given flagsmith = mock.MagicMock() polling_manager = EnvironmentDataPollingManager( @@ -40,7 +41,9 @@ def test_polling_manager_calls_update_environment_on_each_refresh(): polling_manager.stop() -def test_polling_manager_is_resilient_to_api_errors(mocker, server_api_key): +def test_polling_manager_is_resilient_to_api_errors( + mocker: MockerFixture, server_api_key: str +) -> None: # Given session_mock = mocker.patch("requests.Session") session_mock.get.return_value = mock.MagicMock(status_code=500) @@ -56,7 +59,9 @@ def test_polling_manager_is_resilient_to_api_errors(mocker, server_api_key): polling_manager.stop() -def test_polling_manager_is_resilient_to_request_exceptions(mocker, server_api_key): +def test_polling_manager_is_resilient_to_request_exceptions( + mocker: MockerFixture, server_api_key: str +) -> None: # Given session_mock = mocker.patch("requests.Session") session_mock.get.side_effect = requests.RequestException() diff --git a/tests/test_streaming_manager.py b/tests/test_streaming_manager.py index 136f40b..bd4bf93 100644 --- a/tests/test_streaming_manager.py +++ b/tests/test_streaming_manager.py @@ -1,6 +1,5 @@ import time from datetime import datetime -from typing import Generator from unittest.mock import MagicMock, Mock import pytest @@ -14,7 +13,7 @@ def test_stream_manager_handles_timeout( - mocked_responses: Generator["responses.RequestsMock", None, None] + mocked_responses: responses.RequestsMock, ) -> None: stream_url = ( "https://realtime.flagsmith.com/sse/environments/B62qaMZNwfiqT76p38ggrQ/stream" @@ -38,8 +37,8 @@ def test_stream_manager_handles_timeout( def test_stream_manager_handles_request_exception( - mocked_responses: Generator["responses.RequestsMock", None, None], - caplog: Generator["pytest.LogCaptureFixture", None, None], + mocked_responses: responses.RequestsMock, + caplog: pytest.LogCaptureFixture, ) -> None: stream_url = ( "https://realtime.flagsmith.com/sse/environments/B62qaMZNwfiqT76p38ggrQ/stream" @@ -78,13 +77,12 @@ def test_environment_updates_on_recent_event( flagsmith = Flagsmith(environment_key=server_api_key) flagsmith._environment = MagicMock() flagsmith._environment.updated_at = environment_updated_at - flagsmith.handle_stream_event( event=Mock( data=f'{{"updated_at": {stream_updated_at.timestamp()}}}\n\n', ) ) - + assert isinstance(flagsmith.update_environment, Mock) flagsmith.update_environment.assert_called_once() @@ -105,7 +103,7 @@ def test_environment_does_not_update_on_past_event( data=f'{{"updated_at": {stream_updated_at.timestamp()}}}\n\n', ) ) - + assert isinstance(flagsmith.update_environment, Mock) flagsmith.update_environment.assert_not_called() @@ -126,7 +124,7 @@ def test_environment_does_not_update_on_same_event( data=f'{{"updated_at": {stream_updated_at.timestamp()}}}\n\n', ) ) - + assert isinstance(flagsmith.update_environment, Mock) flagsmith.update_environment.assert_not_called()