From 59a1505c078523e9543b6efa6a7135a9032b3c7c Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Wed, 17 Jan 2024 17:43:04 +0000 Subject: [PATCH 01/10] #61 SSE streaming manager --- flagsmith/flagsmith.py | 56 +++++- flagsmith/streaming_manager.py | 44 +++++ poetry.lock | 304 +++++++++++++++++--------------- pyproject.toml | 4 +- tests/conftest.py | 14 +- tests/test_flagsmith.py | 4 +- tests/test_streaming_manager.py | 135 ++++++++++++++ 7 files changed, 411 insertions(+), 150 deletions(-) create mode 100644 flagsmith/streaming_manager.py create mode 100644 tests/test_streaming_manager.py diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 06b41a3..98fc6cf 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -1,6 +1,8 @@ import logging import typing -from json import JSONDecodeError +import json +from datetime import datetime +import pytz import requests from flag_engine import engine @@ -15,11 +17,13 @@ from flagsmith.models import DefaultFlag, Flags, Segment from flagsmith.offline_handlers import BaseOfflineHandler from flagsmith.polling_manager import EnvironmentDataPollingManager +from flagsmith.streaming_manager import EventStreamManager from flagsmith.utils.identities import 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/" class Flagsmith: @@ -41,6 +45,7 @@ def __init__( self, environment_key: str = None, api_url: str = None, + realtime_api_url: str = None, custom_headers: typing.Dict[str, typing.Any] = None, request_timeout_seconds: int = None, enable_local_evaluation: bool = False, @@ -51,11 +56,13 @@ def __init__( proxies: typing.Dict[str, str] = None, offline_mode: bool = False, offline_handler: BaseOfflineHandler = None, + use_stream: bool = False, ): """ :param environment_key: The environment key obtained from Flagsmith interface. Required unless offline_mode is True. :param api_url: Override the URL of the Flagsmith API to communicate with + :param realtime_api_url: Override the URL of the Flagsmith real-time API :param custom_headers: Additional headers to add to requests made to the Flagsmith API :param request_timeout_seconds: Number of seconds to wait for a request to @@ -76,12 +83,14 @@ def __init__( :param offline_handler: provide a handler for offline logic. Used to get environment document from another source when in offline_mode. Works in place of default_flag_handler if offline_mode is not set and using remote evaluation. + :param use_stream: Use real-time functionality via SSE as opposed to polling the API """ self.offline_mode = offline_mode self.enable_local_evaluation = enable_local_evaluation self.offline_handler = offline_handler self.default_flag_handler = default_flag_handler + self.use_stream = use_stream self._analytics_processor = None self._environment = None @@ -110,6 +119,13 @@ def __init__( api_url = api_url or DEFAULT_API_URL self.api_url = api_url if api_url.endswith("/") else f"{api_url}/" + realtime_api_url = realtime_api_url or DEFAULT_REALTIME_API_URL + self.realtime_api_url = ( + realtime_api_url + if realtime_api_url.endswith("/") + else f"{realtime_api_url}/" + ) + self.request_timeout_seconds = request_timeout_seconds self.session.mount(self.api_url, HTTPAdapter(max_retries=retries)) @@ -124,20 +140,45 @@ def __init__( "in the environment settings page." ) - self.environment_data_polling_manager_thread = ( - EnvironmentDataPollingManager( + if self.use_stream: + self.update_environment() + stream_url = f"{self.realtime_api_url}sse/environments/{self._environment.api_key}/stream" + + self.event_stream_thread = EventStreamManager( + stream_url=stream_url, + on_event=self.handle_stream_event, + daemon=True, # noqa + ) + + self.event_stream_thread.start() + + else: + self.environment_data_polling_manager_thread = EnvironmentDataPollingManager( main=self, refresh_interval_seconds=environment_refresh_interval_seconds, daemon=True, # noqa ) - ) - self.environment_data_polling_manager_thread.start() + self.environment_data_polling_manager_thread.start() if enable_analytics: self._analytics_processor = AnalyticsProcessor( environment_key, self.api_url, timeout=self.request_timeout_seconds ) + def handle_stream_event(self, event): + event_data = json.loads(event.data) + stream_updated_at = datetime.fromtimestamp(event_data.get("updated_at")) + + if stream_updated_at.tzinfo is None: + stream_updated_at = pytz.utc.localize(stream_updated_at) + + environment_updated_at = self._environment.updated_at + if environment_updated_at.tzinfo is None: + environment_updated_at = pytz.utc.localize(environment_updated_at) + + if stream_updated_at > environment_updated_at: + self.update_environment() + def get_environment_flags(self) -> Flags: """ Get all the default for flags for the current environment. @@ -267,7 +308,7 @@ def _get_json_response(self, url: str, method: str, body: dict = None): response.status_code, ) return response.json() - except (requests.ConnectionError, JSONDecodeError) as e: + except (requests.ConnectionError, json.JSONDecodeError) as e: raise FlagsmithAPIError( "Unable to get valid response from Flagsmith API." ) from e @@ -291,3 +332,6 @@ def _build_identity_model(self, identifier: str, **traits): def __del__(self): if hasattr(self, "environment_data_polling_manager_thread"): self.environment_data_polling_manager_thread.stop() + + if hasattr(self, "event_stream_thread"): + self.event_stream_thread.stop() diff --git a/flagsmith/streaming_manager.py b/flagsmith/streaming_manager.py new file mode 100644 index 0000000..05ea289 --- /dev/null +++ b/flagsmith/streaming_manager.py @@ -0,0 +1,44 @@ +import threading +import requests +import logging +import sseclient + +from flagsmith.exceptions import FlagsmithAPIError + +logger = logging.getLogger(__name__) + + +class EventStreamManager(threading.Thread): + def __init__( + self, *args, stream_url, on_event, request_timeout_seconds=None, **kwargs + ): + super().__init__(*args, **kwargs) + self._stop_event = threading.Event() + self.stream_url = stream_url + self.on_event = on_event + self.request_timeout_seconds = request_timeout_seconds + + def run(self) -> None: + while not self._stop_event.is_set(): + try: + with requests.get( + self.stream_url, + stream=True, + headers={"Accept": "application/json, text/event-stream"}, + timeout=self.request_timeout_seconds, + ) as response: + sse_client = sseclient.SSEClient(response) + for event in sse_client.events(): + self.on_event(event) + + except requests.exceptions.ReadTimeout: + pass + + except (FlagsmithAPIError, requests.RequestException): + logger.exception("Error handling event stream") + + def stop(self) -> None: + self._stop_event.set() + + def __del__(self): + self._stop_event.set() diff --git a/poetry.lock b/poetry.lock index e9e4411..28c7445 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -213,13 +213,13 @@ files = [ [[package]] name = "distlib" -version = "0.3.7" +version = "0.3.8" description = "Distribution utilities" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, - {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] [[package]] @@ -253,12 +253,12 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "p [[package]] name = "flagsmith-flag-engine" -version = "5.0.0" +version = "5.1.0" description = "Flag engine for the Flagsmith API." optional = false python-versions = "*" files = [ - {file = "flagsmith-flag-engine-5.0.0.tar.gz", hash = "sha256:d89a894f8c79da3b5c4688b979e16d038b29bc34c1dbfea48f74be26d60e4ac9"}, + {file = "flagsmith-flag-engine-5.1.0.tar.gz", hash = "sha256:79cb0c99b19c853f7ee5c444fcc120916e65fa7160b9c72896d32c33857e41dd"}, ] [package.dependencies] @@ -299,13 +299,13 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.4" +version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] [[package]] @@ -481,19 +481,19 @@ files = [ [[package]] name = "pydantic" -version = "2.5.2" +version = "2.5.3" description = "Data validation using Python type hints" optional = false python-versions = ">=3.7" files = [ - {file = "pydantic-2.5.2-py3-none-any.whl", hash = "sha256:80c50fb8e3dcecfddae1adbcc00ec5822918490c99ab31f6cf6140ca1c1429f0"}, - {file = "pydantic-2.5.2.tar.gz", hash = "sha256:ff177ba64c6faf73d7afa2e8cad38fd456c0dbe01c9954e71038001cd15a6edd"}, + {file = "pydantic-2.5.3-py3-none-any.whl", hash = "sha256:d0caf5954bee831b6bfe7e338c32b9e30c85dfe080c843680783ac2b631673b4"}, + {file = "pydantic-2.5.3.tar.gz", hash = "sha256:b3ef57c62535b0941697cce638c08900d87fcb67e29cfa99e8a68f747f393f7a"}, ] [package.dependencies] annotated-types = ">=0.4.0" importlib-metadata = {version = "*", markers = "python_version == \"3.7\""} -pydantic-core = "2.14.5" +pydantic-core = "2.14.6" typing-extensions = ">=4.6.1" [package.extras] @@ -501,13 +501,13 @@ email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-collections" -version = "0.5.1" +version = "0.5.2" description = "Collections of pydantic models" optional = false python-versions = "*" files = [ - {file = "pydantic-collections-0.5.1.tar.gz", hash = "sha256:8b4d2f5a7052dab8d8036cc3d5b013dba20809fd4f43599002a90f40da4653bd"}, - {file = "pydantic_collections-0.5.1-py3-none-any.whl", hash = "sha256:b58f5b17946d997d14f2d92a4d6a0a31b10940dc9e40fcda1ae31bd2d7e62e22"}, + {file = "pydantic-collections-0.5.2.tar.gz", hash = "sha256:48d1317e55342e3df6403a900e8a326d3c8452300c3a9c29e1cf032e09409454"}, + {file = "pydantic_collections-0.5.2-py3-none-any.whl", hash = "sha256:5540c645759e5d52b56b181639c22748936c04d3850cfaa2ac04f2c7169698ba"}, ] [package.dependencies] @@ -516,116 +516,116 @@ typing-extensions = ">=4.7.1" [[package]] name = "pydantic-core" -version = "2.14.5" +version = "2.14.6" description = "" optional = false python-versions = ">=3.7" files = [ - {file = "pydantic_core-2.14.5-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:7e88f5696153dc516ba6e79f82cc4747e87027205f0e02390c21f7cb3bd8abfd"}, - {file = "pydantic_core-2.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4641e8ad4efb697f38a9b64ca0523b557c7931c5f84e0fd377a9a3b05121f0de"}, - {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:774de879d212db5ce02dfbf5b0da9a0ea386aeba12b0b95674a4ce0593df3d07"}, - {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebb4e035e28f49b6f1a7032920bb9a0c064aedbbabe52c543343d39341a5b2a3"}, - {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b53e9ad053cd064f7e473a5f29b37fc4cc9dc6d35f341e6afc0155ea257fc911"}, - {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aa1768c151cf562a9992462239dfc356b3d1037cc5a3ac829bb7f3bda7cc1f9"}, - {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac5c82fc632c599f4639a5886f96867ffced74458c7db61bc9a66ccb8ee3113"}, - {file = "pydantic_core-2.14.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2ae91f50ccc5810b2f1b6b858257c9ad2e08da70bf890dee02de1775a387c66"}, - {file = "pydantic_core-2.14.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6b9ff467ffbab9110e80e8c8de3bcfce8e8b0fd5661ac44a09ae5901668ba997"}, - {file = "pydantic_core-2.14.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:61ea96a78378e3bd5a0be99b0e5ed00057b71f66115f5404d0dae4819f495093"}, - {file = "pydantic_core-2.14.5-cp310-none-win32.whl", hash = "sha256:bb4c2eda937a5e74c38a41b33d8c77220380a388d689bcdb9b187cf6224c9720"}, - {file = "pydantic_core-2.14.5-cp310-none-win_amd64.whl", hash = "sha256:b7851992faf25eac90bfcb7bfd19e1f5ffa00afd57daec8a0042e63c74a4551b"}, - {file = "pydantic_core-2.14.5-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:4e40f2bd0d57dac3feb3a3aed50f17d83436c9e6b09b16af271b6230a2915459"}, - {file = "pydantic_core-2.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ab1cdb0f14dc161ebc268c09db04d2c9e6f70027f3b42446fa11c153521c0e88"}, - {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aae7ea3a1c5bb40c93cad361b3e869b180ac174656120c42b9fadebf685d121b"}, - {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:60b7607753ba62cf0739177913b858140f11b8af72f22860c28eabb2f0a61937"}, - {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2248485b0322c75aee7565d95ad0e16f1c67403a470d02f94da7344184be770f"}, - {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:823fcc638f67035137a5cd3f1584a4542d35a951c3cc68c6ead1df7dac825c26"}, - {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96581cfefa9123accc465a5fd0cc833ac4d75d55cc30b633b402e00e7ced00a6"}, - {file = "pydantic_core-2.14.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a33324437018bf6ba1bb0f921788788641439e0ed654b233285b9c69704c27b4"}, - {file = "pydantic_core-2.14.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9bd18fee0923ca10f9a3ff67d4851c9d3e22b7bc63d1eddc12f439f436f2aada"}, - {file = "pydantic_core-2.14.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:853a2295c00f1d4429db4c0fb9475958543ee80cfd310814b5c0ef502de24dda"}, - {file = "pydantic_core-2.14.5-cp311-none-win32.whl", hash = "sha256:cb774298da62aea5c80a89bd58c40205ab4c2abf4834453b5de207d59d2e1651"}, - {file = "pydantic_core-2.14.5-cp311-none-win_amd64.whl", hash = "sha256:e87fc540c6cac7f29ede02e0f989d4233f88ad439c5cdee56f693cc9c1c78077"}, - {file = "pydantic_core-2.14.5-cp311-none-win_arm64.whl", hash = "sha256:57d52fa717ff445cb0a5ab5237db502e6be50809b43a596fb569630c665abddf"}, - {file = "pydantic_core-2.14.5-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:e60f112ac88db9261ad3a52032ea46388378034f3279c643499edb982536a093"}, - {file = "pydantic_core-2.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6e227c40c02fd873c2a73a98c1280c10315cbebe26734c196ef4514776120aeb"}, - {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0cbc7fff06a90bbd875cc201f94ef0ee3929dfbd5c55a06674b60857b8b85ed"}, - {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:103ef8d5b58596a731b690112819501ba1db7a36f4ee99f7892c40da02c3e189"}, - {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c949f04ecad823f81b1ba94e7d189d9dfb81edbb94ed3f8acfce41e682e48cef"}, - {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c1452a1acdf914d194159439eb21e56b89aa903f2e1c65c60b9d874f9b950e5d"}, - {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb4679d4c2b089e5ef89756bc73e1926745e995d76e11925e3e96a76d5fa51fc"}, - {file = "pydantic_core-2.14.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf9d3fe53b1ee360e2421be95e62ca9b3296bf3f2fb2d3b83ca49ad3f925835e"}, - {file = "pydantic_core-2.14.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:70f4b4851dbb500129681d04cc955be2a90b2248d69273a787dda120d5cf1f69"}, - {file = "pydantic_core-2.14.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:59986de5710ad9613ff61dd9b02bdd2f615f1a7052304b79cc8fa2eb4e336d2d"}, - {file = "pydantic_core-2.14.5-cp312-none-win32.whl", hash = "sha256:699156034181e2ce106c89ddb4b6504c30db8caa86e0c30de47b3e0654543260"}, - {file = "pydantic_core-2.14.5-cp312-none-win_amd64.whl", hash = "sha256:5baab5455c7a538ac7e8bf1feec4278a66436197592a9bed538160a2e7d11e36"}, - {file = "pydantic_core-2.14.5-cp312-none-win_arm64.whl", hash = "sha256:e47e9a08bcc04d20975b6434cc50bf82665fbc751bcce739d04a3120428f3e27"}, - {file = "pydantic_core-2.14.5-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:af36f36538418f3806048f3b242a1777e2540ff9efaa667c27da63d2749dbce0"}, - {file = "pydantic_core-2.14.5-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:45e95333b8418ded64745f14574aa9bfc212cb4fbeed7a687b0c6e53b5e188cd"}, - {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e47a76848f92529879ecfc417ff88a2806438f57be4a6a8bf2961e8f9ca9ec7"}, - {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d81e6987b27bc7d101c8597e1cd2bcaa2fee5e8e0f356735c7ed34368c471550"}, - {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34708cc82c330e303f4ce87758828ef6e457681b58ce0e921b6e97937dd1e2a3"}, - {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c1988019752138b974c28f43751528116bcceadad85f33a258869e641d753"}, - {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e4d090e73e0725b2904fdbdd8d73b8802ddd691ef9254577b708d413bf3006e"}, - {file = "pydantic_core-2.14.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c7d5b5005f177764e96bd584d7bf28d6e26e96f2a541fdddb934c486e36fd59"}, - {file = "pydantic_core-2.14.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a71891847f0a73b1b9eb86d089baee301477abef45f7eaf303495cd1473613e4"}, - {file = "pydantic_core-2.14.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a717aef6971208f0851a2420b075338e33083111d92041157bbe0e2713b37325"}, - {file = "pydantic_core-2.14.5-cp37-none-win32.whl", hash = "sha256:de790a3b5aa2124b8b78ae5faa033937a72da8efe74b9231698b5a1dd9be3405"}, - {file = "pydantic_core-2.14.5-cp37-none-win_amd64.whl", hash = "sha256:6c327e9cd849b564b234da821236e6bcbe4f359a42ee05050dc79d8ed2a91588"}, - {file = "pydantic_core-2.14.5-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:ef98ca7d5995a82f43ec0ab39c4caf6a9b994cb0b53648ff61716370eadc43cf"}, - {file = "pydantic_core-2.14.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6eae413494a1c3f89055da7a5515f32e05ebc1a234c27674a6956755fb2236f"}, - {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcf4e6d85614f7a4956c2de5a56531f44efb973d2fe4a444d7251df5d5c4dcfd"}, - {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6637560562134b0e17de333d18e69e312e0458ee4455bdad12c37100b7cad706"}, - {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:77fa384d8e118b3077cccfcaf91bf83c31fe4dc850b5e6ee3dc14dc3d61bdba1"}, - {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16e29bad40bcf97aac682a58861249ca9dcc57c3f6be22f506501833ddb8939c"}, - {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:531f4b4252fac6ca476fbe0e6f60f16f5b65d3e6b583bc4d87645e4e5ddde331"}, - {file = "pydantic_core-2.14.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:074f3d86f081ce61414d2dc44901f4f83617329c6f3ab49d2bc6c96948b2c26b"}, - {file = "pydantic_core-2.14.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c2adbe22ab4babbca99c75c5d07aaf74f43c3195384ec07ccbd2f9e3bddaecec"}, - {file = "pydantic_core-2.14.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0f6116a558fd06d1b7c2902d1c4cf64a5bd49d67c3540e61eccca93f41418124"}, - {file = "pydantic_core-2.14.5-cp38-none-win32.whl", hash = "sha256:fe0a5a1025eb797752136ac8b4fa21aa891e3d74fd340f864ff982d649691867"}, - {file = "pydantic_core-2.14.5-cp38-none-win_amd64.whl", hash = "sha256:079206491c435b60778cf2b0ee5fd645e61ffd6e70c47806c9ed51fc75af078d"}, - {file = "pydantic_core-2.14.5-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:a6a16f4a527aae4f49c875da3cdc9508ac7eef26e7977952608610104244e1b7"}, - {file = "pydantic_core-2.14.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:abf058be9517dc877227ec3223f0300034bd0e9f53aebd63cf4456c8cb1e0863"}, - {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49b08aae5013640a3bfa25a8eebbd95638ec3f4b2eaf6ed82cf0c7047133f03b"}, - {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c2d97e906b4ff36eb464d52a3bc7d720bd6261f64bc4bcdbcd2c557c02081ed2"}, - {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3128e0bbc8c091ec4375a1828d6118bc20404883169ac95ffa8d983b293611e6"}, - {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88e74ab0cdd84ad0614e2750f903bb0d610cc8af2cc17f72c28163acfcf372a4"}, - {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c339dabd8ee15f8259ee0f202679b6324926e5bc9e9a40bf981ce77c038553db"}, - {file = "pydantic_core-2.14.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3387277f1bf659caf1724e1afe8ee7dbc9952a82d90f858ebb931880216ea955"}, - {file = "pydantic_core-2.14.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ba6b6b3846cfc10fdb4c971980a954e49d447cd215ed5a77ec8190bc93dd7bc5"}, - {file = "pydantic_core-2.14.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ca61d858e4107ce5e1330a74724fe757fc7135190eb5ce5c9d0191729f033209"}, - {file = "pydantic_core-2.14.5-cp39-none-win32.whl", hash = "sha256:ec1e72d6412f7126eb7b2e3bfca42b15e6e389e1bc88ea0069d0cc1742f477c6"}, - {file = "pydantic_core-2.14.5-cp39-none-win_amd64.whl", hash = "sha256:c0b97ec434041827935044bbbe52b03d6018c2897349670ff8fe11ed24d1d4ab"}, - {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:79e0a2cdbdc7af3f4aee3210b1172ab53d7ddb6a2d8c24119b5706e622b346d0"}, - {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:678265f7b14e138d9a541ddabbe033012a2953315739f8cfa6d754cc8063e8ca"}, - {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b15e855ae44f0c6341ceb74df61b606e11f1087e87dcb7482377374aac6abe"}, - {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09b0e985fbaf13e6b06a56d21694d12ebca6ce5414b9211edf6f17738d82b0f8"}, - {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3ad873900297bb36e4b6b3f7029d88ff9829ecdc15d5cf20161775ce12306f8a"}, - {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2d0ae0d8670164e10accbeb31d5ad45adb71292032d0fdb9079912907f0085f4"}, - {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d37f8ec982ead9ba0a22a996129594938138a1503237b87318392a48882d50b7"}, - {file = "pydantic_core-2.14.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:35613015f0ba7e14c29ac6c2483a657ec740e5ac5758d993fdd5870b07a61d8b"}, - {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ab4ea451082e684198636565224bbb179575efc1658c48281b2c866bfd4ddf04"}, - {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ce601907e99ea5b4adb807ded3570ea62186b17f88e271569144e8cca4409c7"}, - {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb2ed8b3fe4bf4506d6dab3b93b83bbc22237e230cba03866d561c3577517d18"}, - {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:70f947628e074bb2526ba1b151cee10e4c3b9670af4dbb4d73bc8a89445916b5"}, - {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4bc536201426451f06f044dfbf341c09f540b4ebdb9fd8d2c6164d733de5e634"}, - {file = "pydantic_core-2.14.5-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4791cf0f8c3104ac668797d8c514afb3431bc3305f5638add0ba1a5a37e0d88"}, - {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:038c9f763e650712b899f983076ce783175397c848da04985658e7628cbe873b"}, - {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:27548e16c79702f1e03f5628589c6057c9ae17c95b4c449de3c66b589ead0520"}, - {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c97bee68898f3f4344eb02fec316db93d9700fb1e6a5b760ffa20d71d9a46ce3"}, - {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9b759b77f5337b4ea024f03abc6464c9f35d9718de01cfe6bae9f2e139c397e"}, - {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:439c9afe34638ace43a49bf72d201e0ffc1a800295bed8420c2a9ca8d5e3dbb3"}, - {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ba39688799094c75ea8a16a6b544eb57b5b0f3328697084f3f2790892510d144"}, - {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ccd4d5702bb90b84df13bd491be8d900b92016c5a455b7e14630ad7449eb03f8"}, - {file = "pydantic_core-2.14.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:81982d78a45d1e5396819bbb4ece1fadfe5f079335dd28c4ab3427cd95389944"}, - {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:7f8210297b04e53bc3da35db08b7302a6a1f4889c79173af69b72ec9754796b8"}, - {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:8c8a8812fe6f43a3a5b054af6ac2d7b8605c7bcab2804a8a7d68b53f3cd86e00"}, - {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:206ed23aecd67c71daf5c02c3cd19c0501b01ef3cbf7782db9e4e051426b3d0d"}, - {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2027d05c8aebe61d898d4cffd774840a9cb82ed356ba47a90d99ad768f39789"}, - {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40180930807ce806aa71eda5a5a5447abb6b6a3c0b4b3b1b1962651906484d68"}, - {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:615a0a4bff11c45eb3c1996ceed5bdaa2f7b432425253a7c2eed33bb86d80abc"}, - {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5e412d717366e0677ef767eac93566582518fe8be923361a5c204c1a62eaafe"}, - {file = "pydantic_core-2.14.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:513b07e99c0a267b1d954243845d8a833758a6726a3b5d8948306e3fe14675e3"}, - {file = "pydantic_core-2.14.5.tar.gz", hash = "sha256:6d30226dfc816dd0fdf120cae611dd2215117e4f9b124af8c60ab9093b6e8e71"}, + {file = "pydantic_core-2.14.6-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:72f9a942d739f09cd42fffe5dc759928217649f070056f03c70df14f5770acf9"}, + {file = "pydantic_core-2.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6a31d98c0d69776c2576dda4b77b8e0c69ad08e8b539c25c7d0ca0dc19a50d6c"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5aa90562bc079c6c290f0512b21768967f9968e4cfea84ea4ff5af5d917016e4"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:370ffecb5316ed23b667d99ce4debe53ea664b99cc37bfa2af47bc769056d534"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f85f3843bdb1fe80e8c206fe6eed7a1caeae897e496542cee499c374a85c6e08"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9862bf828112e19685b76ca499b379338fd4c5c269d897e218b2ae8fcb80139d"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:036137b5ad0cb0004c75b579445a1efccd072387a36c7f217bb8efd1afbe5245"}, + {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92879bce89f91f4b2416eba4429c7b5ca22c45ef4a499c39f0c5c69257522c7c"}, + {file = "pydantic_core-2.14.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0c08de15d50fa190d577e8591f0329a643eeaed696d7771760295998aca6bc66"}, + {file = "pydantic_core-2.14.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:36099c69f6b14fc2c49d7996cbf4f87ec4f0e66d1c74aa05228583225a07b590"}, + {file = "pydantic_core-2.14.6-cp310-none-win32.whl", hash = "sha256:7be719e4d2ae6c314f72844ba9d69e38dff342bc360379f7c8537c48e23034b7"}, + {file = "pydantic_core-2.14.6-cp310-none-win_amd64.whl", hash = "sha256:36fa402dcdc8ea7f1b0ddcf0df4254cc6b2e08f8cd80e7010d4c4ae6e86b2a87"}, + {file = "pydantic_core-2.14.6-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:dea7fcd62915fb150cdc373212141a30037e11b761fbced340e9db3379b892d4"}, + {file = "pydantic_core-2.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffff855100bc066ff2cd3aa4a60bc9534661816b110f0243e59503ec2df38421"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b027c86c66b8627eb90e57aee1f526df77dc6d8b354ec498be9a757d513b92b"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00b1087dabcee0b0ffd104f9f53d7d3eaddfaa314cdd6726143af6bc713aa27e"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75ec284328b60a4e91010c1acade0c30584f28a1f345bc8f72fe8b9e46ec6a96"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e1f4744eea1501404b20b0ac059ff7e3f96a97d3e3f48ce27a139e053bb370b"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2602177668f89b38b9f84b7b3435d0a72511ddef45dc14446811759b82235a1"}, + {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c8edaea3089bf908dd27da8f5d9e395c5b4dc092dbcce9b65e7156099b4b937"}, + {file = "pydantic_core-2.14.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:478e9e7b360dfec451daafe286998d4a1eeaecf6d69c427b834ae771cad4b622"}, + {file = "pydantic_core-2.14.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b6ca36c12a5120bad343eef193cc0122928c5c7466121da7c20f41160ba00ba2"}, + {file = "pydantic_core-2.14.6-cp311-none-win32.whl", hash = "sha256:2b8719037e570639e6b665a4050add43134d80b687288ba3ade18b22bbb29dd2"}, + {file = "pydantic_core-2.14.6-cp311-none-win_amd64.whl", hash = "sha256:78ee52ecc088c61cce32b2d30a826f929e1708f7b9247dc3b921aec367dc1b23"}, + {file = "pydantic_core-2.14.6-cp311-none-win_arm64.whl", hash = "sha256:a19b794f8fe6569472ff77602437ec4430f9b2b9ec7a1105cfd2232f9ba355e6"}, + {file = "pydantic_core-2.14.6-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:667aa2eac9cd0700af1ddb38b7b1ef246d8cf94c85637cbb03d7757ca4c3fdec"}, + {file = "pydantic_core-2.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdee837710ef6b56ebd20245b83799fce40b265b3b406e51e8ccc5b85b9099b7"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c5bcf3414367e29f83fd66f7de64509a8fd2368b1edf4351e862910727d3e51"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a92ae76f75d1915806b77cf459811e772d8f71fd1e4339c99750f0e7f6324f"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a983cca5ed1dd9a35e9e42ebf9f278d344603bfcb174ff99a5815f953925140a"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb92f9061657287eded380d7dc455bbf115430b3aa4741bdc662d02977e7d0af"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4ace1e220b078c8e48e82c081e35002038657e4b37d403ce940fa679e57113b"}, + {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef633add81832f4b56d3b4c9408b43d530dfca29e68fb1b797dcb861a2c734cd"}, + {file = "pydantic_core-2.14.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7e90d6cc4aad2cc1f5e16ed56e46cebf4877c62403a311af20459c15da76fd91"}, + {file = "pydantic_core-2.14.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e8a5ac97ea521d7bde7621d86c30e86b798cdecd985723c4ed737a2aa9e77d0c"}, + {file = "pydantic_core-2.14.6-cp312-none-win32.whl", hash = "sha256:f27207e8ca3e5e021e2402ba942e5b4c629718e665c81b8b306f3c8b1ddbb786"}, + {file = "pydantic_core-2.14.6-cp312-none-win_amd64.whl", hash = "sha256:b3e5fe4538001bb82e2295b8d2a39356a84694c97cb73a566dc36328b9f83b40"}, + {file = "pydantic_core-2.14.6-cp312-none-win_arm64.whl", hash = "sha256:64634ccf9d671c6be242a664a33c4acf12882670b09b3f163cd00a24cffbd74e"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:24368e31be2c88bd69340fbfe741b405302993242ccb476c5c3ff48aeee1afe0"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:e33b0834f1cf779aa839975f9d8755a7c2420510c0fa1e9fa0497de77cd35d2c"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6af4b3f52cc65f8a0bc8b1cd9676f8c21ef3e9132f21fed250f6958bd7223bed"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15687d7d7f40333bd8266f3814c591c2e2cd263fa2116e314f60d82086e353a"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:095b707bb287bfd534044166ab767bec70a9bba3175dcdc3371782175c14e43c"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94fc0e6621e07d1e91c44e016cc0b189b48db053061cc22d6298a611de8071bb"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce830e480f6774608dedfd4a90c42aac4a7af0a711f1b52f807130c2e434c06"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a306cdd2ad3a7d795d8e617a58c3a2ed0f76c8496fb7621b6cd514eb1532cae8"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2f5fa187bde8524b1e37ba894db13aadd64faa884657473b03a019f625cee9a8"}, + {file = "pydantic_core-2.14.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:438027a975cc213a47c5d70672e0d29776082155cfae540c4e225716586be75e"}, + {file = "pydantic_core-2.14.6-cp37-none-win32.whl", hash = "sha256:f96ae96a060a8072ceff4cfde89d261837b4294a4f28b84a28765470d502ccc6"}, + {file = "pydantic_core-2.14.6-cp37-none-win_amd64.whl", hash = "sha256:e646c0e282e960345314f42f2cea5e0b5f56938c093541ea6dbf11aec2862391"}, + {file = "pydantic_core-2.14.6-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:db453f2da3f59a348f514cfbfeb042393b68720787bbef2b4c6068ea362c8149"}, + {file = "pydantic_core-2.14.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3860c62057acd95cc84044e758e47b18dcd8871a328ebc8ccdefd18b0d26a21b"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36026d8f99c58d7044413e1b819a67ca0e0b8ebe0f25e775e6c3d1fabb3c38fb"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ed1af8692bd8d2a29d702f1a2e6065416d76897d726e45a1775b1444f5928a7"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:314ccc4264ce7d854941231cf71b592e30d8d368a71e50197c905874feacc8a8"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:982487f8931067a32e72d40ab6b47b1628a9c5d344be7f1a4e668fb462d2da42"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dbe357bc4ddda078f79d2a36fc1dd0494a7f2fad83a0a684465b6f24b46fe80"}, + {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f6ffc6701a0eb28648c845f4945a194dc7ab3c651f535b81793251e1185ac3d"}, + {file = "pydantic_core-2.14.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7f5025db12fc6de7bc1104d826d5aee1d172f9ba6ca936bf6474c2148ac336c1"}, + {file = "pydantic_core-2.14.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:dab03ed811ed1c71d700ed08bde8431cf429bbe59e423394f0f4055f1ca0ea60"}, + {file = "pydantic_core-2.14.6-cp38-none-win32.whl", hash = "sha256:dfcbebdb3c4b6f739a91769aea5ed615023f3c88cb70df812849aef634c25fbe"}, + {file = "pydantic_core-2.14.6-cp38-none-win_amd64.whl", hash = "sha256:99b14dbea2fdb563d8b5a57c9badfcd72083f6006caf8e126b491519c7d64ca8"}, + {file = "pydantic_core-2.14.6-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:4ce8299b481bcb68e5c82002b96e411796b844d72b3e92a3fbedfe8e19813eab"}, + {file = "pydantic_core-2.14.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b9a9d92f10772d2a181b5ca339dee066ab7d1c9a34ae2421b2a52556e719756f"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd9e98b408384989ea4ab60206b8e100d8687da18b5c813c11e92fd8212a98e0"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f86f1f318e56f5cbb282fe61eb84767aee743ebe32c7c0834690ebea50c0a6b"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86ce5fcfc3accf3a07a729779d0b86c5d0309a4764c897d86c11089be61da160"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dcf1978be02153c6a31692d4fbcc2a3f1db9da36039ead23173bc256ee3b91b"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eedf97be7bc3dbc8addcef4142f4b4164066df0c6f36397ae4aaed3eb187d8ab"}, + {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d5f916acf8afbcab6bacbb376ba7dc61f845367901ecd5e328fc4d4aef2fcab0"}, + {file = "pydantic_core-2.14.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8a14c192c1d724c3acbfb3f10a958c55a2638391319ce8078cb36c02283959b9"}, + {file = "pydantic_core-2.14.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0348b1dc6b76041516e8a854ff95b21c55f5a411c3297d2ca52f5528e49d8411"}, + {file = "pydantic_core-2.14.6-cp39-none-win32.whl", hash = "sha256:de2a0645a923ba57c5527497daf8ec5df69c6eadf869e9cd46e86349146e5975"}, + {file = "pydantic_core-2.14.6-cp39-none-win_amd64.whl", hash = "sha256:aca48506a9c20f68ee61c87f2008f81f8ee99f8d7f0104bff3c47e2d148f89d9"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:d5c28525c19f5bb1e09511669bb57353d22b94cf8b65f3a8d141c389a55dec95"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:78d0768ee59baa3de0f4adac9e3748b4b1fffc52143caebddfd5ea2961595277"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b93785eadaef932e4fe9c6e12ba67beb1b3f1e5495631419c784ab87e975670"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a874f21f87c485310944b2b2734cd6d318765bcbb7515eead33af9641816506e"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89f4477d915ea43b4ceea6756f63f0288941b6443a2b28c69004fe07fde0d0d"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:172de779e2a153d36ee690dbc49c6db568d7b33b18dc56b69a7514aecbcf380d"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dfcebb950aa7e667ec226a442722134539e77c575f6cfaa423f24371bb8d2e94"}, + {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:55a23dcd98c858c0db44fc5c04fc7ed81c4b4d33c653a7c45ddaebf6563a2f66"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:4241204e4b36ab5ae466ecec5c4c16527a054c69f99bba20f6f75232a6a534e2"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e574de99d735b3fc8364cba9912c2bec2da78775eba95cbb225ef7dda6acea24"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1302a54f87b5cd8528e4d6d1bf2133b6aa7c6122ff8e9dc5220fbc1e07bffebd"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8e81e4b55930e5ffab4a68db1af431629cf2e4066dbdbfef65348b8ab804ea8"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c99462ffc538717b3e60151dfaf91125f637e801f5ab008f81c402f1dff0cd0f"}, + {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e4cf2d5829f6963a5483ec01578ee76d329eb5caf330ecd05b3edd697e7d768a"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:cf10b7d58ae4a1f07fccbf4a0a956d705356fea05fb4c70608bb6fa81d103cda"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:399ac0891c284fa8eb998bcfa323f2234858f5d2efca3950ae58c8f88830f145"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c6a5c79b28003543db3ba67d1df336f253a87d3112dac3a51b94f7d48e4c0e1"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:599c87d79cab2a6a2a9df4aefe0455e61e7d2aeede2f8577c1b7c0aec643ee8e"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e166ad47ba900f2542a80d83f9fc65fe99eb63ceec4debec160ae729824052"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a0b5db001b98e1c649dd55afa928e75aa4087e587b9524a4992316fa23c9fba"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:747265448cb57a9f37572a488a57d873fd96bf51e5bb7edb52cfb37124516da4"}, + {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7ebe3416785f65c28f4f9441e916bfc8a54179c8dea73c23023f7086fa601c5d"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:86c963186ca5e50d5c8287b1d1c9d3f8f024cbe343d048c5bd282aec2d8641f2"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e0641b506486f0b4cd1500a2a65740243e8670a2549bb02bc4556a83af84ae03"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71d72ca5eaaa8d38c8df16b7deb1a2da4f650c41b58bb142f3fb75d5ad4a611f"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27e524624eace5c59af499cd97dc18bb201dc6a7a2da24bfc66ef151c69a5f2a"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3dde6cac75e0b0902778978d3b1646ca9f438654395a362cb21d9ad34b24acf"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:00646784f6cd993b1e1c0e7b0fdcbccc375d539db95555477771c27555e3c556"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:23598acb8ccaa3d1d875ef3b35cb6376535095e9405d91a3d57a8c7db5d29341"}, + {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7f41533d7e3cf9520065f610b41ac1c76bc2161415955fbcead4981b22c7611e"}, + {file = "pydantic_core-2.14.6.tar.gz", hash = "sha256:1fd0c1d395372843fba13a51c28e3bb9d59bd7aebfeb17358ffaaa1e4dbbe948"}, ] [package.dependencies] @@ -644,13 +644,13 @@ files = [ [[package]] name = "pytest" -version = "7.4.3" +version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, - {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, ] [package.dependencies] @@ -682,6 +682,17 @@ pytest = ">=5.0" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "pytz" +version = "2023.3.post1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, + {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, +] + [[package]] name = "pyyaml" version = "6.0.1" @@ -781,22 +792,24 @@ dev = ["black (>=22.3.0)", "build (>=0.7.0)", "isort (>=5.11.4)", "pyflakes (>=2 [[package]] name = "responses" -version = "0.17.0" +version = "0.23.3" 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.7" 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.23.3-py3-none-any.whl", hash = "sha256:e6fbcf5d82172fecc0aa1860fd91e58cbfd96cee5e96da5b63fa6eb3caa10dd3"}, + {file = "responses-0.23.3.tar.gz", hash = "sha256:205029e1cb334c21cb4ec64fc7599be48b859a0fd381a42443cdd600bfe8b16a"}, ] [package.dependencies] -requests = ">=2.0" -six = "*" -urllib3 = ">=1.25.10" +pyyaml = "*" +requests = ">=2.30.0,<3.0" +types-PyYAML = "*" +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} +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-requests"] [[package]] name = "semver" @@ -826,14 +839,14 @@ testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[l testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "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" +name = "sseclient-py" +version = "1.8.0" +description = "SSE client for Python" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "*" files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "sseclient-py-1.8.0.tar.gz", hash = "sha256:c547c5c1a7633230a38dc599a21a2dc638f9b5c297286b48b46b935c71fac3e8"}, + {file = "sseclient_py-1.8.0-py2.py3-none-any.whl", hash = "sha256:4ecca6dc0b9f963f8384e9d7fd529bf93dd7d708144c4fb5da0e0a1a926fee83"}, ] [[package]] @@ -897,6 +910,17 @@ files = [ {file = "typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd"}, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.12" +description = "Typing stubs for PyYAML" +optional = false +python-versions = "*" +files = [ + {file = "types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062"}, + {file = "types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24"}, +] + [[package]] name = "typing-extensions" version = "4.7.1" @@ -964,4 +988,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7.0,<4" -content-hash = "d513b76114ceb0b46104589ab61cda88138ec8256b904d7aadfd04bc606497b4" +content-hash = "bbc59b203a239f52abb5ee1452e958d57a36c1f55523bd2d41a40d4465352bb9" diff --git a/pyproject.toml b/pyproject.toml index 59003b9..286ef1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,13 +14,15 @@ python = ">=3.7.0,<4" requests = "^2.27.1" requests-futures = "^1.0.0" flagsmith-flag-engine = "^5.0.0" +sseclient-py = "^1.8.0" +pytz = "^2023.3.post1" [tool.poetry.dev-dependencies] pytest = "^7.4.0" pytest-mock = "^3.6.1" black = "^23.3.0" pre-commit = "^2.17.0" -responses = "^0.17.0" +responses = "^0.23.3" flake8 = "^4.0.1" isort = "^5.10.1" diff --git a/tests/conftest.py b/tests/conftest.py index b8105a0..787bb7d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import string import pytest +import responses from flag_engine.environments.models import EnvironmentModel from flagsmith import Flagsmith @@ -41,7 +42,7 @@ def environment_json(): @pytest.fixture() -def local_eval_flagsmith(server_api_key, environment_json, mocker): +def requests_session_response_ok(mocker, environment_json): mock_session = mocker.MagicMock() mocker.patch("flagsmith.flagsmith.requests.Session", return_value=mock_session) @@ -49,6 +50,11 @@ def local_eval_flagsmith(server_api_key, environment_json, mocker): mock_environment_document_response.json.return_value = json.loads(environment_json) mock_session.get.return_value = mock_environment_document_response + return mock_session + + +@pytest.fixture() +def local_eval_flagsmith(requests_session_response_ok, server_api_key): flagsmith = Flagsmith( environment_key=server_api_key, enable_local_evaluation=True, @@ -75,3 +81,9 @@ def flags_json(): def identities_json(): with open(os.path.join(DATA_DIR, "identities.json"), "rt") as f: yield f.read() + + +@pytest.fixture +def mocked_responses(): + with responses.RequestsMock() as rsps: + yield rsps diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index baaa9cb..52e0ddb 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -421,8 +421,8 @@ def test_flagsmith_uses_offline_handler_if_set_and_no_api_response( offline_handler=mock_offline_handler, ) - responses.add(flagsmith.environment_flags_url, status=500) - responses.add(flagsmith.identities_url, status=500) + responses.get(flagsmith.environment_flags_url, status=500) + responses.get(flagsmith.identities_url, status=500) # When environment_flags = flagsmith.get_environment_flags() diff --git a/tests/test_streaming_manager.py b/tests/test_streaming_manager.py new file mode 100644 index 0000000..263927b --- /dev/null +++ b/tests/test_streaming_manager.py @@ -0,0 +1,135 @@ +import time +from datetime import datetime +import requests +from unittest.mock import MagicMock, Mock, patch + +from flagsmith import Flagsmith +from flagsmith.exceptions import FlagsmithAPIError +from flagsmith.streaming_manager import EventStreamManager + + +def test_stream_not_used_by_default(requests_session_response_ok, server_api_key): + flagsmith = Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=True, + ) + + assert hasattr(flagsmith, "event_stream_thread") is False + + +def test_stream_used_when_use_stream_is_true( + requests_session_response_ok, server_api_key +): + flagsmith = Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=True, + use_stream=True, + ) + + assert hasattr(flagsmith, "event_stream_thread") is True + + +def test_stream_manager_handles_timeout(mocked_responses): + stream_url = ( + "https://realtime.flagsmith.com/sse/environments/B62qaMZNwfiqT76p38ggrQ/stream" + ) + + mocked_responses.get(stream_url, body=requests.exceptions.ReadTimeout()) + + streaming_manager = EventStreamManager( + stream_url=stream_url, + on_event=MagicMock(), + daemon=True, + ) + + streaming_manager.start() + + time.sleep(0.01) + + assert streaming_manager.is_alive() + + streaming_manager.stop() + + +def test_stream_manager_handles_request_exception(mocked_responses, caplog): + stream_url = ( + "https://realtime.flagsmith.com/sse/environments/B62qaMZNwfiqT76p38ggrQ/stream" + ) + + mocked_responses.get(stream_url, body=requests.RequestException()) + mocked_responses.get(stream_url, body=FlagsmithAPIError()) + + streaming_manager = EventStreamManager( + stream_url=stream_url, + on_event=MagicMock(), + daemon=True, + ) + + streaming_manager.start() + + time.sleep(0.01) + + assert streaming_manager.is_alive() + + streaming_manager.stop() + + for record in caplog.records: + assert record.levelname == "ERROR" + assert record.message == "Error handling event stream" + + +def test_environment_updates_on_recent_event(server_api_key, mocker): + stream_updated_at = datetime(2020, 1, 1, 1, 1, 2) + environment_updated_at = datetime(2020, 1, 1, 1, 1, 1) + + mocker.patch("flagsmith.Flagsmith.update_environment") + + 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', + ) + ) + + flagsmith.update_environment.assert_called_once() + + +def test_environment_does_not_update_on_past_event(server_api_key, mocker): + stream_updated_at = datetime(2020, 1, 1, 1, 1, 1) + environment_updated_at = datetime(2020, 1, 1, 1, 1, 2) + + mocker.patch("flagsmith.Flagsmith.update_environment") + + 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', + ) + ) + + flagsmith.update_environment.assert_not_called() + + +def test_environment_does_not_update_on_same_event(server_api_key, mocker): + stream_updated_at = datetime(2020, 1, 1, 1, 1, 1) + environment_updated_at = datetime(2020, 1, 1, 1, 1, 1) + + mocker.patch("flagsmith.Flagsmith.update_environment") + + 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', + ) + ) + + flagsmith.update_environment.assert_not_called() From 1636aa5add4ba3c00f8eb0acd6723b56f76fea79 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Thu, 18 Jan 2024 10:59:49 +0000 Subject: [PATCH 02/10] Remove extraneous import --- tests/test_streaming_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_streaming_manager.py b/tests/test_streaming_manager.py index 263927b..6424b24 100644 --- a/tests/test_streaming_manager.py +++ b/tests/test_streaming_manager.py @@ -1,7 +1,7 @@ import time from datetime import datetime import requests -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock from flagsmith import Flagsmith from flagsmith.exceptions import FlagsmithAPIError From b672d37458e529925fab46a5bc1145f3b2de051e Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Thu, 18 Jan 2024 11:36:54 +0000 Subject: [PATCH 03/10] isort fixes --- flagsmith/flagsmith.py | 4 ++-- flagsmith/streaming_manager.py | 3 ++- tests/test_streaming_manager.py | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 98fc6cf..01eb09f 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -1,9 +1,9 @@ +import json import logging import typing -import json from datetime import datetime -import pytz +import pytz import requests from flag_engine import engine from flag_engine.environments.models import EnvironmentModel diff --git a/flagsmith/streaming_manager.py b/flagsmith/streaming_manager.py index 05ea289..e6c253d 100644 --- a/flagsmith/streaming_manager.py +++ b/flagsmith/streaming_manager.py @@ -1,6 +1,7 @@ +import logging import threading + import requests -import logging import sseclient from flagsmith.exceptions import FlagsmithAPIError diff --git a/tests/test_streaming_manager.py b/tests/test_streaming_manager.py index 6424b24..f15a45a 100644 --- a/tests/test_streaming_manager.py +++ b/tests/test_streaming_manager.py @@ -1,8 +1,9 @@ import time from datetime import datetime -import requests from unittest.mock import MagicMock, Mock +import requests + from flagsmith import Flagsmith from flagsmith.exceptions import FlagsmithAPIError from flagsmith.streaming_manager import EventStreamManager From aebd4745151de476e8d37849d18dc7280cd1e8d8 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Thu, 18 Jan 2024 12:21:19 +0000 Subject: [PATCH 04/10] Self CR --- flagsmith/flagsmith.py | 13 ++++++++-- tests/test_streaming_manager.py | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 01eb09f..dfeff04 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -166,8 +166,17 @@ def __init__( ) def handle_stream_event(self, event): - event_data = json.loads(event.data) - stream_updated_at = datetime.fromtimestamp(event_data.get("updated_at")) + try: + event_data = json.loads(event.data) + except json.JSONDecodeError as e: + raise FlagsmithAPIError("Unable to get valid json from event data.") from e + + try: + stream_updated_at = datetime.fromtimestamp(event_data.get("updated_at")) + except TypeError as e: + raise FlagsmithAPIError( + "Unable to get valid timestamp from event data." + ) from e if stream_updated_at.tzinfo is None: stream_updated_at = pytz.utc.localize(stream_updated_at) diff --git a/tests/test_streaming_manager.py b/tests/test_streaming_manager.py index f15a45a..373f308 100644 --- a/tests/test_streaming_manager.py +++ b/tests/test_streaming_manager.py @@ -2,6 +2,7 @@ from datetime import datetime from unittest.mock import MagicMock, Mock +import pytest import requests from flagsmith import Flagsmith @@ -134,3 +135,48 @@ def test_environment_does_not_update_on_same_event(server_api_key, mocker): ) flagsmith.update_environment.assert_not_called() + + +def test_invalid_json_payload(server_api_key, mocker): + mocker.patch("flagsmith.Flagsmith.update_environment") + flagsmith = Flagsmith(environment_key=server_api_key) + + with pytest.raises(FlagsmithAPIError): + flagsmith.handle_stream_event( + event=Mock( + data='{"updated_at": test}\n\n', + ) + ) + + with pytest.raises(FlagsmithAPIError): + flagsmith.handle_stream_event( + event=Mock( + data="{{test}}\n\n", + ) + ) + + with pytest.raises(FlagsmithAPIError): + flagsmith.handle_stream_event( + event=Mock( + data="test", + ) + ) + + +def test_invalid_timestamp_in_payload(server_api_key, mocker): + mocker.patch("flagsmith.Flagsmith.update_environment") + flagsmith = Flagsmith(environment_key=server_api_key) + + with pytest.raises(FlagsmithAPIError): + flagsmith.handle_stream_event( + event=Mock( + data='{"updated_at": "test"}\n\n', + ) + ) + + with pytest.raises(FlagsmithAPIError): + flagsmith.handle_stream_event( + event=Mock( + data='{"test": "test"}\n\n', + ) + ) From 085dac9774be79865fddcc8609ee109adc1dfb80 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Thu, 25 Jan 2024 14:01:54 +0000 Subject: [PATCH 05/10] CR changes --- flagsmith/flagsmith.py | 13 +++++++++---- tests/test_streaming_manager.py | 15 +++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index dfeff04..5eeb75a 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -56,7 +56,7 @@ def __init__( proxies: typing.Dict[str, str] = None, offline_mode: bool = False, offline_handler: BaseOfflineHandler = None, - use_stream: bool = False, + enable_realtime_updates: bool = False, ): """ :param environment_key: The environment key obtained from Flagsmith interface. @@ -83,14 +83,14 @@ def __init__( :param offline_handler: provide a handler for offline logic. Used to get environment document from another source when in offline_mode. Works in place of default_flag_handler if offline_mode is not set and using remote evaluation. - :param use_stream: Use real-time functionality via SSE as opposed to polling the API + :param enable_realtime_updates: Use real-time functionality via SSE as opposed to polling the API """ self.offline_mode = offline_mode self.enable_local_evaluation = enable_local_evaluation self.offline_handler = offline_handler self.default_flag_handler = default_flag_handler - self.use_stream = use_stream + self.enable_realtime_updates = enable_realtime_updates self._analytics_processor = None self._environment = None @@ -102,6 +102,11 @@ def __init__( "Cannot use both default_flag_handler and offline_handler." ) + if self.enable_realtime_updates and not self.enable_local_evaluation: + raise ValueError( + "Can only use realtime updates when running in local evaluation mode." + ) + if self.offline_handler: self._environment = self.offline_handler.get_environment() @@ -140,7 +145,7 @@ def __init__( "in the environment settings page." ) - if self.use_stream: + if self.enable_realtime_updates: self.update_environment() stream_url = f"{self.realtime_api_url}sse/environments/{self._environment.api_key}/stream" diff --git a/tests/test_streaming_manager.py b/tests/test_streaming_manager.py index 373f308..b45836e 100644 --- a/tests/test_streaming_manager.py +++ b/tests/test_streaming_manager.py @@ -19,18 +19,29 @@ def test_stream_not_used_by_default(requests_session_response_ok, server_api_key assert hasattr(flagsmith, "event_stream_thread") is False -def test_stream_used_when_use_stream_is_true( +def test_stream_used_when_enable_realtime_updates_is_true( requests_session_response_ok, server_api_key ): flagsmith = Flagsmith( environment_key=server_api_key, enable_local_evaluation=True, - use_stream=True, + enable_realtime_updates=True, ) assert hasattr(flagsmith, "event_stream_thread") is True +def test_error_raised_when_realtime_updates_is_true_and_local_evaluation_false( + requests_session_response_ok, server_api_key +): + with pytest.raises(ValueError): + Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=False, + enable_realtime_updates=True, + ) + + def test_stream_manager_handles_timeout(mocked_responses): stream_url = ( "https://realtime.flagsmith.com/sse/environments/B62qaMZNwfiqT76p38ggrQ/stream" From 5289c440ed22fc989914107646804b55870814f2 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Thu, 25 Jan 2024 14:20:33 +0000 Subject: [PATCH 06/10] Simplify Flagsmith.__init__ to keep flake8 happy --- flagsmith/flagsmith.py | 47 ++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 5eeb75a..f38206b 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -88,6 +88,7 @@ def __init__( self.offline_mode = offline_mode self.enable_local_evaluation = enable_local_evaluation + self.environment_refresh_interval_seconds = environment_refresh_interval_seconds self.offline_handler = offline_handler self.default_flag_handler = default_flag_handler self.enable_realtime_updates = enable_realtime_updates @@ -102,7 +103,7 @@ def __init__( "Cannot use both default_flag_handler and offline_handler." ) - if self.enable_realtime_updates and not self.enable_local_evaluation: + if enable_realtime_updates and not enable_local_evaluation: raise ValueError( "Can only use realtime updates when running in local evaluation mode." ) @@ -145,31 +146,37 @@ def __init__( "in the environment settings page." ) - if self.enable_realtime_updates: - self.update_environment() - stream_url = f"{self.realtime_api_url}sse/environments/{self._environment.api_key}/stream" - - self.event_stream_thread = EventStreamManager( - stream_url=stream_url, - on_event=self.handle_stream_event, - daemon=True, # noqa - ) - - self.event_stream_thread.start() - - else: - self.environment_data_polling_manager_thread = EnvironmentDataPollingManager( - main=self, - refresh_interval_seconds=environment_refresh_interval_seconds, - daemon=True, # noqa - ) - self.environment_data_polling_manager_thread.start() + self.initialise_local_evaluation() if enable_analytics: self._analytics_processor = AnalyticsProcessor( environment_key, self.api_url, timeout=self.request_timeout_seconds ) + def initialise_local_evaluation(self): + if self.enable_realtime_updates: + self.update_environment() + stream_url = f"{self.realtime_api_url}sse/environments/{self._environment.api_key}/stream" + + self.event_stream_thread = EventStreamManager( + stream_url=stream_url, + on_event=self.handle_stream_event, + daemon=True, # noqa + ) + + self.event_stream_thread.start() + + else: + self.environment_data_polling_manager_thread = ( + EnvironmentDataPollingManager( + main=self, + refresh_interval_seconds=self.environment_refresh_interval_seconds, + daemon=True, # noqa + ) + ) + + self.environment_data_polling_manager_thread.start() + def handle_stream_event(self, event): try: event_data = json.loads(event.data) From a73aeb7a1bf83dcaa65b902c36a10ace5a7d3491 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Mon, 29 Jan 2024 16:03:56 +0000 Subject: [PATCH 07/10] Remove extraneous noqa --- flagsmith/flagsmith.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index f38206b..8f398f7 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -161,7 +161,7 @@ def initialise_local_evaluation(self): self.event_stream_thread = EventStreamManager( stream_url=stream_url, on_event=self.handle_stream_event, - daemon=True, # noqa + daemon=True, ) self.event_stream_thread.start() @@ -171,7 +171,7 @@ def initialise_local_evaluation(self): EnvironmentDataPollingManager( main=self, refresh_interval_seconds=self.environment_refresh_interval_seconds, - daemon=True, # noqa + daemon=True, ) ) From 63266a3001d48af21867b9bcf6576c6c9bc794a1 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Mon, 29 Jan 2024 18:19:08 +0000 Subject: [PATCH 08/10] Adds typing to new methods and tests --- flagsmith/flagsmith.py | 10 +++--- flagsmith/streaming_manager.py | 20 ++++++++--- poetry.lock | 12 +++---- pyproject.toml | 2 +- tests/conftest.py | 17 ++++++--- tests/test_flagsmith.py | 40 +++++++++++++++++++++ tests/test_streaming_manager.py | 62 ++++++++++++--------------------- 7 files changed, 103 insertions(+), 60 deletions(-) diff --git a/flagsmith/flagsmith.py b/flagsmith/flagsmith.py index 8f398f7..7a807af 100644 --- a/flagsmith/flagsmith.py +++ b/flagsmith/flagsmith.py @@ -17,7 +17,7 @@ from flagsmith.models import DefaultFlag, Flags, Segment from flagsmith.offline_handlers import BaseOfflineHandler from flagsmith.polling_manager import EnvironmentDataPollingManager -from flagsmith.streaming_manager import EventStreamManager +from flagsmith.streaming_manager import EventStreamManager, StreamEvent from flagsmith.utils.identities import generate_identities_data logger = logging.getLogger(__name__) @@ -45,7 +45,7 @@ def __init__( self, environment_key: str = None, api_url: str = None, - realtime_api_url: str = None, + realtime_api_url: typing.Optional[str] = None, custom_headers: typing.Dict[str, typing.Any] = None, request_timeout_seconds: int = None, enable_local_evaluation: bool = False, @@ -146,14 +146,14 @@ def __init__( "in the environment settings page." ) - self.initialise_local_evaluation() + self._initialise_local_evaluation() if enable_analytics: self._analytics_processor = AnalyticsProcessor( environment_key, self.api_url, timeout=self.request_timeout_seconds ) - def initialise_local_evaluation(self): + def _initialise_local_evaluation(self) -> None: if self.enable_realtime_updates: self.update_environment() stream_url = f"{self.realtime_api_url}sse/environments/{self._environment.api_key}/stream" @@ -177,7 +177,7 @@ def initialise_local_evaluation(self): self.environment_data_polling_manager_thread.start() - def handle_stream_event(self, event): + def handle_stream_event(self, event: StreamEvent) -> None: try: event_data = json.loads(event.data) except json.JSONDecodeError as e: diff --git a/flagsmith/streaming_manager.py b/flagsmith/streaming_manager.py index e6c253d..04251a9 100644 --- a/flagsmith/streaming_manager.py +++ b/flagsmith/streaming_manager.py @@ -1,5 +1,6 @@ import logging import threading +from typing import Callable, Generator, Protocol, cast import requests import sseclient @@ -9,10 +10,19 @@ logger = logging.getLogger(__name__) +class StreamEvent(Protocol): + data: str + + class EventStreamManager(threading.Thread): def __init__( - self, *args, stream_url, on_event, request_timeout_seconds=None, **kwargs - ): + self, + *args, + stream_url: str, + on_event: Callable[[StreamEvent], None], + request_timeout_seconds: int | None = None, + **kwargs + ) -> None: super().__init__(*args, **kwargs) self._stop_event = threading.Event() self.stream_url = stream_url @@ -28,7 +38,9 @@ def run(self) -> None: headers={"Accept": "application/json, text/event-stream"}, timeout=self.request_timeout_seconds, ) as response: - sse_client = sseclient.SSEClient(response) + sse_client = sseclient.SSEClient( + cast(Generator[bytes, None, None], response) + ) for event in sse_client.events(): self.on_event(event) @@ -41,5 +53,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/poetry.lock b/poetry.lock index 28c7445..63bb41a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -253,12 +253,12 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "p [[package]] name = "flagsmith-flag-engine" -version = "5.1.0" +version = "5.1.1" description = "Flag engine for the Flagsmith API." optional = false python-versions = "*" files = [ - {file = "flagsmith-flag-engine-5.1.0.tar.gz", hash = "sha256:79cb0c99b19c853f7ee5c444fcc120916e65fa7160b9c72896d32c33857e41dd"}, + {file = "flagsmith-flag-engine-5.1.1.tar.gz", hash = "sha256:a97d001ac50fcddee273a25d8c88442b33797fde5b4d657f3e83e1493aa4f536"}, ] [package.dependencies] @@ -684,13 +684,13 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "pytz" -version = "2023.3.post1" +version = "2023.4" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, - {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, + {file = "pytz-2023.4-py2.py3-none-any.whl", hash = "sha256:f90ef520d95e7c46951105338d918664ebfd6f1d995bd7d153127ce90efafa6a"}, + {file = "pytz-2023.4.tar.gz", hash = "sha256:31d4583c4ed539cd037956140d695e42c033a19e984bfce9964a3f7d59bc2b40"}, ] [[package]] @@ -988,4 +988,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = ">=3.7.0,<4" -content-hash = "bbc59b203a239f52abb5ee1452e958d57a36c1f55523bd2d41a40d4465352bb9" +content-hash = "a137ed145a3f91426ee3973bc0166357799ebfaca0807a5941b4d58fa6253b70" diff --git a/pyproject.toml b/pyproject.toml index 286ef1d..59fde13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ requests = "^2.27.1" requests-futures = "^1.0.0" flagsmith-flag-engine = "^5.0.0" sseclient-py = "^1.8.0" -pytz = "^2023.3.post1" +pytz = "^2023.4" [tool.poetry.dev-dependencies] pytest = "^7.4.0" diff --git a/tests/conftest.py b/tests/conftest.py index 787bb7d..6116e72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,12 @@ import os import random import string +from typing import Generator, TypeAlias import pytest import responses from flag_engine.environments.models import EnvironmentModel +from pytest_mock import MockerFixture from flagsmith import Flagsmith from flagsmith.analytics import AnalyticsProcessor @@ -13,6 +15,9 @@ DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +SetupFixture: TypeAlias = None + + @pytest.fixture() def analytics_processor(): return AnalyticsProcessor( @@ -42,7 +47,9 @@ def environment_json(): @pytest.fixture() -def requests_session_response_ok(mocker, environment_json): +def requests_session_response_ok( + mocker: Generator[MockerFixture, None, None], environment_json: str +) -> SetupFixture: mock_session = mocker.MagicMock() mocker.patch("flagsmith.flagsmith.requests.Session", return_value=mock_session) @@ -50,11 +57,11 @@ def requests_session_response_ok(mocker, environment_json): mock_environment_document_response.json.return_value = json.loads(environment_json) mock_session.get.return_value = mock_environment_document_response - return mock_session - @pytest.fixture() -def local_eval_flagsmith(requests_session_response_ok, server_api_key): +def local_eval_flagsmith( + requests_session_response_ok: SetupFixture, server_api_key: str +) -> Generator[Flagsmith, None, None]: flagsmith = Flagsmith( environment_key=server_api_key, enable_local_evaluation=True, @@ -84,6 +91,6 @@ def identities_json(): @pytest.fixture -def mocked_responses(): +def mocked_responses() -> Generator["responses.RequestsMock", None, None]: with responses.RequestsMock() as rsps: yield rsps diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index 52e0ddb..5234d4b 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -16,6 +16,8 @@ from flag_engine.environments.models import EnvironmentModel from pytest_mock import MockerFixture +from .conftest import SetupFixture + def test_flagsmith_starts_polling_manager_on_init_if_enabled(mocker, server_api_key): # Given @@ -474,3 +476,41 @@ def test_cannot_create_flagsmith_client_in_remote_evaluation_without_api_key(): # Then assert e.exconly() == "ValueError: environment_key is required." + + +def test_stream_not_used_by_default( + requests_session_response_ok: SetupFixture, server_api_key: str +) -> None: + # When + flagsmith = Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=True, + ) + + # Then + assert hasattr(flagsmith, "event_stream_thread") is False + + +def test_stream_used_when_enable_realtime_updates_is_true( + requests_session_response_ok: SetupFixture, server_api_key: str +) -> None: + # When + flagsmith = Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=True, + enable_realtime_updates=True, + ) + + # Then + assert hasattr(flagsmith, "event_stream_thread") is True + + +def test_error_raised_when_realtime_updates_is_true_and_local_evaluation_false( + requests_session_response_ok: SetupFixture, server_api_key: str +) -> None: + with pytest.raises(ValueError): + Flagsmith( + environment_key=server_api_key, + enable_local_evaluation=False, + enable_realtime_updates=True, + ) diff --git a/tests/test_streaming_manager.py b/tests/test_streaming_manager.py index b45836e..136f40b 100644 --- a/tests/test_streaming_manager.py +++ b/tests/test_streaming_manager.py @@ -1,48 +1,21 @@ import time from datetime import datetime +from typing import Generator from unittest.mock import MagicMock, Mock import pytest import requests +import responses +from pytest_mock import MockerFixture from flagsmith import Flagsmith from flagsmith.exceptions import FlagsmithAPIError from flagsmith.streaming_manager import EventStreamManager -def test_stream_not_used_by_default(requests_session_response_ok, server_api_key): - flagsmith = Flagsmith( - environment_key=server_api_key, - enable_local_evaluation=True, - ) - - assert hasattr(flagsmith, "event_stream_thread") is False - - -def test_stream_used_when_enable_realtime_updates_is_true( - requests_session_response_ok, server_api_key -): - flagsmith = Flagsmith( - environment_key=server_api_key, - enable_local_evaluation=True, - enable_realtime_updates=True, - ) - - assert hasattr(flagsmith, "event_stream_thread") is True - - -def test_error_raised_when_realtime_updates_is_true_and_local_evaluation_false( - requests_session_response_ok, server_api_key -): - with pytest.raises(ValueError): - Flagsmith( - environment_key=server_api_key, - enable_local_evaluation=False, - enable_realtime_updates=True, - ) - - -def test_stream_manager_handles_timeout(mocked_responses): +def test_stream_manager_handles_timeout( + mocked_responses: Generator["responses.RequestsMock", None, None] +) -> None: stream_url = ( "https://realtime.flagsmith.com/sse/environments/B62qaMZNwfiqT76p38ggrQ/stream" ) @@ -64,7 +37,10 @@ def test_stream_manager_handles_timeout(mocked_responses): streaming_manager.stop() -def test_stream_manager_handles_request_exception(mocked_responses, caplog): +def test_stream_manager_handles_request_exception( + mocked_responses: Generator["responses.RequestsMock", None, None], + caplog: Generator["pytest.LogCaptureFixture", None, None], +) -> None: stream_url = ( "https://realtime.flagsmith.com/sse/environments/B62qaMZNwfiqT76p38ggrQ/stream" ) @@ -91,7 +67,9 @@ def test_stream_manager_handles_request_exception(mocked_responses, caplog): assert record.message == "Error handling event stream" -def test_environment_updates_on_recent_event(server_api_key, mocker): +def test_environment_updates_on_recent_event( + server_api_key: str, mocker: MockerFixture +) -> None: stream_updated_at = datetime(2020, 1, 1, 1, 1, 2) environment_updated_at = datetime(2020, 1, 1, 1, 1, 1) @@ -110,7 +88,9 @@ def test_environment_updates_on_recent_event(server_api_key, mocker): flagsmith.update_environment.assert_called_once() -def test_environment_does_not_update_on_past_event(server_api_key, mocker): +def test_environment_does_not_update_on_past_event( + server_api_key: str, mocker: MockerFixture +) -> None: stream_updated_at = datetime(2020, 1, 1, 1, 1, 1) environment_updated_at = datetime(2020, 1, 1, 1, 1, 2) @@ -129,7 +109,9 @@ def test_environment_does_not_update_on_past_event(server_api_key, mocker): flagsmith.update_environment.assert_not_called() -def test_environment_does_not_update_on_same_event(server_api_key, mocker): +def test_environment_does_not_update_on_same_event( + server_api_key: str, mocker: MockerFixture +) -> None: stream_updated_at = datetime(2020, 1, 1, 1, 1, 1) environment_updated_at = datetime(2020, 1, 1, 1, 1, 1) @@ -148,7 +130,7 @@ def test_environment_does_not_update_on_same_event(server_api_key, mocker): flagsmith.update_environment.assert_not_called() -def test_invalid_json_payload(server_api_key, mocker): +def test_invalid_json_payload(server_api_key: str, mocker: MockerFixture) -> None: mocker.patch("flagsmith.Flagsmith.update_environment") flagsmith = Flagsmith(environment_key=server_api_key) @@ -174,7 +156,9 @@ def test_invalid_json_payload(server_api_key, mocker): ) -def test_invalid_timestamp_in_payload(server_api_key, mocker): +def test_invalid_timestamp_in_payload( + server_api_key: str, mocker: MockerFixture +) -> None: mocker.patch("flagsmith.Flagsmith.update_environment") flagsmith = Flagsmith(environment_key=server_api_key) From 57f117dd66e612b82d6689c12dd4b48c0acd7386 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Mon, 29 Jan 2024 18:24:30 +0000 Subject: [PATCH 09/10] Remove typing.TypeAlias Incompatible with python <= 3.9 --- tests/conftest.py | 9 +++------ tests/test_flagsmith.py | 8 +++----- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6116e72..d13c761 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ import os import random import string -from typing import Generator, TypeAlias +from typing import Generator import pytest import responses @@ -15,9 +15,6 @@ DATA_DIR = os.path.join(os.path.dirname(__file__), "data") -SetupFixture: TypeAlias = None - - @pytest.fixture() def analytics_processor(): return AnalyticsProcessor( @@ -49,7 +46,7 @@ def environment_json(): @pytest.fixture() def requests_session_response_ok( mocker: Generator[MockerFixture, None, None], environment_json: str -) -> SetupFixture: +) -> None: mock_session = mocker.MagicMock() mocker.patch("flagsmith.flagsmith.requests.Session", return_value=mock_session) @@ -60,7 +57,7 @@ def requests_session_response_ok( @pytest.fixture() def local_eval_flagsmith( - requests_session_response_ok: SetupFixture, server_api_key: str + requests_session_response_ok: None, server_api_key: str ) -> Generator[Flagsmith, None, None]: flagsmith = Flagsmith( environment_key=server_api_key, diff --git a/tests/test_flagsmith.py b/tests/test_flagsmith.py index 5234d4b..5c1cd3b 100644 --- a/tests/test_flagsmith.py +++ b/tests/test_flagsmith.py @@ -16,8 +16,6 @@ from flag_engine.environments.models import EnvironmentModel from pytest_mock import MockerFixture -from .conftest import SetupFixture - def test_flagsmith_starts_polling_manager_on_init_if_enabled(mocker, server_api_key): # Given @@ -479,7 +477,7 @@ def test_cannot_create_flagsmith_client_in_remote_evaluation_without_api_key(): def test_stream_not_used_by_default( - requests_session_response_ok: SetupFixture, server_api_key: str + requests_session_response_ok: None, server_api_key: str ) -> None: # When flagsmith = Flagsmith( @@ -492,7 +490,7 @@ def test_stream_not_used_by_default( def test_stream_used_when_enable_realtime_updates_is_true( - requests_session_response_ok: SetupFixture, server_api_key: str + requests_session_response_ok: None, server_api_key: str ) -> None: # When flagsmith = Flagsmith( @@ -506,7 +504,7 @@ def test_stream_used_when_enable_realtime_updates_is_true( def test_error_raised_when_realtime_updates_is_true_and_local_evaluation_false( - requests_session_response_ok: SetupFixture, server_api_key: str + requests_session_response_ok: None, server_api_key: str ) -> None: with pytest.raises(ValueError): Flagsmith( From fecf70b2e0a96c8f80df9af3955da88069b87288 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Mon, 29 Jan 2024 18:31:16 +0000 Subject: [PATCH 10/10] Use Optional as opposed to pipe --- flagsmith/streaming_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flagsmith/streaming_manager.py b/flagsmith/streaming_manager.py index 04251a9..64ac6a7 100644 --- a/flagsmith/streaming_manager.py +++ b/flagsmith/streaming_manager.py @@ -1,6 +1,6 @@ import logging import threading -from typing import Callable, Generator, Protocol, cast +from typing import Callable, Generator, Optional, Protocol, cast import requests import sseclient @@ -20,7 +20,7 @@ def __init__( *args, stream_url: str, on_event: Callable[[StreamEvent], None], - request_timeout_seconds: int | None = None, + request_timeout_seconds: Optional[int] = None, **kwargs ) -> None: super().__init__(*args, **kwargs)