diff --git a/client/lib/main.dart b/client/lib/main.dart index b56654779..ec2423e68 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -37,6 +37,10 @@ void main([List? args]) async { throw Exception('Page URL must be provided as a first argument.'); } pageUrl = args[0]; + if (args.length > 1) { + var pidFile = await File(args[1]).create(); + await pidFile.writeAsString("$pid"); + } } debugPrint("Page URL: $pageUrl"); diff --git a/client/macos/Runner.xcodeproj/project.pbxproj b/client/macos/Runner.xcodeproj/project.pbxproj index c31c95112..363fe4b4a 100644 --- a/client/macos/Runner.xcodeproj/project.pbxproj +++ b/client/macos/Runner.xcodeproj/project.pbxproj @@ -55,7 +55,7 @@ /* Begin PBXFileReference section */ 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* flet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flet.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* Flet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Flet.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -112,7 +112,7 @@ 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* flet.app */, + 33CC10ED2044A3C60003C045 /* Flet.app */, ); name = Products; sourceTree = ""; @@ -159,7 +159,6 @@ EDAD244E5F1A9F15957004F8 /* Pods-Runner.release.xcconfig */, 3BFA430DEADD5855D5604A50 /* Pods-Runner.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -193,7 +192,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* flet.app */; + productReference = 33CC10ED2044A3C60003C045 /* Flet.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ diff --git a/client/macos/Runner/DebugProfile.entitlements b/client/macos/Runner/DebugProfile.entitlements index 08c3ab17c..78c36cf44 100644 --- a/client/macos/Runner/DebugProfile.entitlements +++ b/client/macos/Runner/DebugProfile.entitlements @@ -3,7 +3,7 @@ com.apple.security.app-sandbox - + com.apple.security.cs.allow-jit com.apple.security.network.server diff --git a/client/macos/Runner/Release.entitlements b/client/macos/Runner/Release.entitlements index ee95ab7e5..08ba3a3fa 100644 --- a/client/macos/Runner/Release.entitlements +++ b/client/macos/Runner/Release.entitlements @@ -3,7 +3,7 @@ com.apple.security.app-sandbox - + com.apple.security.network.client diff --git a/package/lib/src/controls/audio.dart b/package/lib/src/controls/audio.dart index 9a872d0b3..52d206728 100644 --- a/package/lib/src/controls/audio.dart +++ b/package/lib/src/controls/audio.dart @@ -182,7 +182,7 @@ class _AudioControlState extends State { sendResult(Object? result, String? error) { ws.pageEventFromWeb( eventTarget: widget.control.id, - eventName: "result", + eventName: "method_result", eventData: json.encode({ "i": i, "r": result != null ? json.encode(result) : null, diff --git a/sdk/python/flet/async_connection.py b/sdk/python/flet/async_connection.py new file mode 100644 index 000000000..182391f36 --- /dev/null +++ b/sdk/python/flet/async_connection.py @@ -0,0 +1,182 @@ +import asyncio +import json +import logging +from typing import List, Optional +import uuid +from flet import constants +from flet.connection import Connection +import websockets.client as ws_client +from websockets.client import WebSocketClientProtocol +from flet.protocol import * +from asyncio.queues import Queue + + +class AsyncConnection(Connection): + __CONNECT_TIMEOUT = 0.2 + __CONNECT_ATTEMPTS = 50 + + def __init__( + self, + server_address: str, + page_name: str, + auth_token: Optional[str], + on_event=None, + on_session_created=None, + ): + super().__init__() + self.__send_queue = Queue(1) + self.page_name = page_name + self.__server_address = server_address + self.__is_reconnecting = False + self.__host_client_id: Optional[str] = None + self.__auth_token = auth_token + self.__ws_callbacks = {} + self.__on_event = on_event + self.__on_session_created = on_session_created + + async def connect(self): + ws_url = self._get_ws_url(self.__server_address) + logging.debug(f"Connecting via WebSockets to {ws_url}...") + + attempt = self.__CONNECT_ATTEMPTS + while True: + try: + self.__ws: WebSocketClientProtocol = await ws_client.connect(ws_url) + break + except Exception as e: + logging.debug(f"Error connecting to Flet server: {e}") + if attempt == 0 and not self.__is_reconnecting: + raise Exception( + f"Failed to connect Flet server in {self.__CONNECT_ATTEMPTS} attempts." + ) + attempt -= 1 + await asyncio.sleep(self.__CONNECT_TIMEOUT) + logging.debug(f"Connected to Flet server {self.__server_address}") + self.__is_reconnecting = True + + # start send/receive loops + asyncio.get_event_loop().create_task(self.__start_loops()) + + await self.__register_host_client() + + async def __register_host_client(self): + payload = RegisterHostClientRequestPayload( + hostClientID=self.__host_client_id, + pageName=self.page_name, + isApp=True, + update=False, + authToken=self.__auth_token, + permissions=None, + ) + response = await self._send_message_with_result( + Actions.REGISTER_HOST_CLIENT, payload + ) + register_result = RegisterHostClientResponsePayload(**response) + self.__host_client_id = register_result.hostClientID + self.page_name = register_result.pageName + self.page_url = self.__server_address.rstrip("/") + if self.page_name != constants.INDEX_PAGE: + self.page_url += f"/{self.page_name}" + + async def __start_loops(self): + self.__receive_loop_task = asyncio.create_task(self.__receive_looop()) + self.__send_loop_task = asyncio.create_task(self.__send_loop()) + done, pending = await asyncio.wait( + [self.__receive_loop_task, self.__send_loop_task], + return_when=asyncio.FIRST_COMPLETED, + ) + failed = False + for task in done: + name = task.get_name() + exception = task.exception() + if isinstance(exception, Exception): + logging.error(f"{name} threw {exception}") + failed = True + for task in pending: + task.cancel() + + # re-connect if one of tasks failed + if failed: + logging.debug(f"Re-connecting to Flet server in 1 second") + await asyncio.sleep(self.__CONNECT_TIMEOUT) + await self.connect() + + async def __on_ws_message(self, data): + logging.debug(f"_on_message: {data}") + msg_dict = json.loads(data) + msg = Message(**msg_dict) + if msg.id: + # callback + evt = self.__ws_callbacks[msg.id][0] + self.__ws_callbacks[msg.id] = (None, msg.payload) + evt.set() + elif msg.action == Actions.PAGE_EVENT_TO_HOST: + if self.__on_event is not None: + asyncio.create_task(self.__on_event(PageEventPayload(**msg.payload))) + elif msg.action == Actions.SESSION_CREATED: + if self.__on_session_created is not None: + asyncio.create_task( + self.__on_session_created(PageSessionCreatedPayload(**msg.payload)) + ) + else: + # it's something else + print(msg.payload) + + async def __receive_looop(self): + async for message in self.__ws: + await self.__on_ws_message(message) + + async def __send_loop(self): + while True: + message = await self.__send_queue.get() + try: + await self.__ws.send(message) + except: + # re-enqueue the message to repeat it when re-connected + self.__send_queue.put_nowait(message) + raise + + async def send_command_async(self, session_id: str, command: Command): + assert self.page_name is not None + payload = PageCommandRequestPayload(self.page_name, session_id, command) + response = await self._send_message_with_result( + Actions.PAGE_COMMAND_FROM_HOST, payload + ) + result = PageCommandResponsePayload(**response) + if result.error: + raise Exception(result.error) + return result + + async def send_commands_async(self, session_id: str, commands: List[Command]): + assert self.page_name is not None + payload = PageCommandsBatchRequestPayload(self.page_name, session_id, commands) + response = await self._send_message_with_result( + Actions.PAGE_COMMANDS_BATCH_FROM_HOST, payload + ) + result = PageCommandsBatchResponsePayload(**response) + if result.error: + raise Exception(result.error) + return result + + async def _send_message_with_result(self, action_name, payload): + msg_id = uuid.uuid4().hex + msg = Message(msg_id, action_name, payload) + j = json.dumps(msg, cls=CommandEncoder, separators=(",", ":")) + logging.debug(f"_send_message_with_result: {j}") + evt = asyncio.Event() + self.__ws_callbacks[msg_id] = (evt, None) + await self.__send_queue.put(j) + await evt.wait() + return self.__ws_callbacks.pop(msg_id)[1] + + async def close(self): + logging.debug("Closing WebSockets connection...") + if self.__receive_loop_task: + self.__receive_loop_task.cancel() + if self.__send_loop_task: + self.__send_loop_task.cancel() + if self.__ws: + try: + await self.__ws.close() + except: + pass # do nothing diff --git a/sdk/python/flet/audio.py b/sdk/python/flet/audio.py index 044ad285b..1e6725754 100644 --- a/sdk/python/flet/audio.py +++ b/sdk/python/flet/audio.py @@ -1,12 +1,10 @@ -import dataclasses -import json -import threading from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any, Optional from beartype import beartype +from flet.callable_control import CallableControl -from flet.control import Control, OptionalNumber +from flet.control import OptionalNumber from flet.ref import Ref @@ -16,21 +14,7 @@ class ReleaseMode(Enum): STOP = "stop" -@dataclasses.dataclass -class AudioMethodCall: - i: int - n: str - p: List[str] - - -@dataclasses.dataclass -class AudioMethodResults: - i: int - r: Optional[str] - e: Optional[str] - - -class Audio(Control): +class Audio(CallableControl): """ A control to simultaneously play multiple audio files. Works on macOS, Linux, Windows, iOS, Android and web. Based on audioplayers Flutter widget (https://pub.dev/packages/audioplayers). @@ -57,6 +41,7 @@ def main(page: ft.Page): Online docs: https://flet.dev/docs/controls/audio """ + def __init__( self, src: Optional[str] = None, @@ -76,16 +61,12 @@ def __init__( on_seek_complete=None, ): - Control.__init__( + CallableControl.__init__( self, ref=ref, data=data, ) - self.__call_counter = 0 - self.__calls: Dict[int, threading.Event] = {} - self.__results: Dict[threading.Event, tuple[Optional[str], Optional[str]]] = {} - self._add_event_handler("result", self._on_result) self.src = src self.src_base64 = src_base64 self.autoplay = autoplay @@ -105,61 +86,52 @@ def _get_control_name(self): def play(self): self._call_method("play", params=[], wait_for_result=False) + async def play_async(self): + await self._call_method_async("play", params=[], wait_for_result=False) + def pause(self): self._call_method("pause", params=[], wait_for_result=False) + async def pause_async(self): + await self._call_method_async("pause", params=[], wait_for_result=False) + def resume(self): self._call_method("resume", params=[], wait_for_result=False) + async def resume_async(self): + await self._call_method_async("resume", params=[], wait_for_result=False) + def release(self): self._call_method("release", params=[], wait_for_result=False) + async def release_async(self): + await self._call_method_async("release", params=[], wait_for_result=False) + def seek(self, position_milliseconds: int): self._call_method( "seek", params=[str(position_milliseconds)], wait_for_result=False ) + async def seek_async(self, position_milliseconds: int): + await self._call_method_async( + "seek", params=[str(position_milliseconds)], wait_for_result=False + ) + def get_duration(self) -> Optional[int]: sr = self._call_method("get_duration", []) return int(sr) if sr else None + async def get_duration_async(self) -> Optional[int]: + sr = await self._call_method_async("get_duration", []) + return int(sr) if sr else None + def get_current_position(self) -> Optional[int]: sr = self._call_method("get_current_position", []) return int(sr) if sr else None - def _call_method(self, name: str, params: List[str], wait_for_result=True) -> Any: - m = AudioMethodCall(i=self.__call_counter, n=name, p=params) - self.__call_counter += 1 - self._set_attr_json("method", m) - - evt: Optional[threading.Event] = None - if wait_for_result: - evt = threading.Event() - self.__calls[m.i] = evt - self.update() - - if not wait_for_result: - return - - assert evt is not None - if not evt.wait(5): - del self.__calls[m.i] - raise Exception(f"Timeout waiting for Audio.{name}({params}) method call") - result, err = self.__results.pop(evt) - if err != None: - raise Exception(err) - if result == None: - return None - return json.loads(result) - - def _on_result(self, e): - d = json.loads(e.data) - result = AudioMethodResults(**d) - evt = self.__calls.pop(result.i, None) - if evt == None: - return - self.__results[evt] = (result.r, result.e) - evt.set() + async def get_current_position_async(self) -> Optional[int]: + sr = await self._call_method_async("get_current_position", []) + return int(sr) if sr else None # src @property diff --git a/sdk/python/flet/auth/authorization.py b/sdk/python/flet/auth/authorization.py index 7f92b6bb6..1e6272ca4 100644 --- a/sdk/python/flet/auth/authorization.py +++ b/sdk/python/flet/auth/authorization.py @@ -1,10 +1,12 @@ +import asyncio import json import secrets import threading import time from typing import List, Optional, Tuple +from flet.utils import is_asyncio +import httpx -import requests from oauthlib.oauth2 import WebApplicationClient from oauthlib.oauth2.rfc6749.tokens import OAuth2Token @@ -12,6 +14,8 @@ from flet.auth.oauth_token import OAuthToken from flet.auth.user import User +from flet.version import version + class Authorization: def __init__( @@ -20,7 +24,6 @@ def __init__( fetch_user: bool, fetch_groups: bool, scope: Optional[List[str]] = None, - saved_token: Optional[str] = None, ) -> None: self.fetch_user = fetch_user self.fetch_groups = fetch_groups @@ -28,7 +31,8 @@ def __init__( self.provider = provider self.__token: Optional[OAuthToken] = None self.user: Optional[User] = None - self._lock = threading.Lock() + self.__lock = threading.Lock() + self.__async_lock = asyncio.Lock() # fix scopes self.scope.extend(self.provider.scopes) @@ -41,18 +45,30 @@ def __init__( if s not in self.scope: self.scope.append(s) - if saved_token != None: - self.__token = OAuthToken.from_json(saved_token) - self.__refresh_token() - self.__fetch_user_and_groups() + def dehydrate_token(self, saved_token: str): + self.__token = OAuthToken.from_json(saved_token) + self.__refresh_token() + self.__fetch_user_and_groups() + + async def dehydrate_token_async(self, saved_token: str): + self.__token = OAuthToken.from_json(saved_token) + await self.__refresh_token_async() + await self.__fetch_user_and_groups_async() # token @property def token(self) -> Optional[OAuthToken]: - with self._lock: + with self.__lock: self.__refresh_token() return self.__token + # token_async + @property + async def token_async(self) -> Optional[OAuthToken]: + async with self.__async_lock: + await self.__refresh_token_async() + return self.__token + def get_authorization_data(self) -> Tuple[str, str]: self.state = secrets.token_urlsafe(16) client = WebApplicationClient(self.provider.client_id) @@ -65,6 +81,26 @@ def get_authorization_data(self) -> Tuple[str, str]: return (authorization_url, self.state) def request_token(self, code: str): + req = self.__get_request_token_request(code) + with httpx.Client(follow_redirects=True) as client: + resp = client.send(req) + resp.raise_for_status() + client = WebApplicationClient(self.provider.client_id) + t = client.parse_request_body_response(resp.text) + self.__token = self.__convert_token(t) + self.__fetch_user_and_groups() + + async def request_token_async(self, code: str): + req = self.__get_request_token_request(code) + async with httpx.AsyncClient(follow_redirects=True) as client: + resp = await client.send(req) + resp.raise_for_status() + client = WebApplicationClient(self.provider.client_id) + t = client.parse_request_body_response(resp.text) + self.__token = self.__convert_token(t) + await self.__fetch_user_and_groups_async() + + def __get_request_token_request(self, code: str): client = WebApplicationClient(self.provider.client_id) data = client.prepare_request_body( code=code, @@ -72,13 +108,11 @@ def request_token(self, code: str): client_id=self.provider.client_id, client_secret=self.provider.client_secret, ) - headers = {"content-type": "application/x-www-form-urlencoded"} - response = requests.post( - self.provider.token_endpoint, data=data, headers=headers + headers = self.__get_default_headers() + headers["content-type"] = "application/x-www-form-urlencoded" + return httpx.Request( + "POST", self.provider.token_endpoint, content=data, headers=headers ) - t = client.parse_request_body_response(response.text) - self.__token = self.__convert_token(t) - self.__fetch_user_and_groups() def __fetch_user_and_groups(self): assert self.__token is not None @@ -95,6 +129,21 @@ def __fetch_user_and_groups(self): self.__token.access_token ) + async def __fetch_user_and_groups_async(self): + assert self.__token is not None + if self.fetch_user: + self.user = await self.provider._fetch_user_async(self.__token.access_token) + if self.user == None and self.provider.user_endpoint != None: + if self.provider.user_id_fn == None: + raise Exception( + "user_id_fn must be specified too if user_endpoint is not None" + ) + self.user = await self.__get_user_async() + if self.fetch_groups and self.user != None: + self.user.groups = await self.provider._fetch_groups_async( + self.__token.access_token + ) + def __convert_token(self, t: OAuth2Token): return OAuthToken( access_token=t["access_token"], @@ -106,13 +155,27 @@ def __convert_token(self, t: OAuth2Token): ) def __refresh_token(self): + refresh_req = self.__get_refresh_token_request() + if refresh_req: + with httpx.Client(follow_redirects=True) as client: + refresh_resp = client.send(refresh_req) + self.__complete_refresh_token_request(refresh_resp) + + async def __refresh_token_async(self): + refresh_req = self.__get_refresh_token_request() + if refresh_req: + async with httpx.AsyncClient(follow_redirects=True) as client: + refresh_resp = await client.send(refresh_req) + self.__complete_refresh_token_request(refresh_resp) + + def __get_refresh_token_request(self): if ( self.__token is None or self.__token.expires_at is None or self.__token.refresh_token is None or time.time() < self.__token.expires_at ): - return + return None assert self.__token is not None client = WebApplicationClient(self.provider.client_id) @@ -122,20 +185,47 @@ def __refresh_token(self): refresh_token=self.__token.refresh_token, redirect_uri=self.provider.redirect_url, ) - headers = {"content-type": "application/x-www-form-urlencoded"} - response = requests.post( - self.provider.token_endpoint, data=data, headers=headers + headers = self.__get_default_headers() + headers["content-type"] = "application/x-www-form-urlencoded" + return httpx.Request( + "POST", url=self.provider.token_endpoint, content=data, headers=headers ) - t = client.parse_request_body_response(response.text) + + def __complete_refresh_token_request(self, refresh_resp): + refresh_resp.raise_for_status() + assert self.__token is not None + client = WebApplicationClient(self.provider.client_id) + t = client.parse_request_body_response(refresh_resp.text) if t.get("refresh_token") == None: t["refresh_token"] = self.__token.refresh_token self.__token = self.__convert_token(t) def __get_user(self): + user_req = self.__get_user_request() + with httpx.Client() as client: + user_resp = client.send(user_req) + return self.__complete_user_request(user_resp) + + async def __get_user_async(self): + user_req = self.__get_user_request() + async with httpx.AsyncClient(follow_redirects=True) as client: + user_resp = await client.send(user_req) + return self.__complete_user_request(user_resp) + + def __get_user_request(self): assert self.token is not None assert self.provider.user_endpoint is not None + headers = self.__get_default_headers() + headers["Authorization"] = "Bearer {}".format(self.token.access_token) + return httpx.Request("GET", self.provider.user_endpoint, headers=headers) + + def __complete_user_request(self, user_resp): + user_resp.raise_for_status() assert self.provider.user_id_fn is not None - headers = {"Authorization": "Bearer {}".format(self.token.access_token)} - user_resp = requests.get(self.provider.user_endpoint, headers=headers) uj = json.loads(user_resp.text) return User(uj, str(self.provider.user_id_fn(uj))) + + def __get_default_headers(self): + return { + "User-Agent": "Flet/{}".format(version), + } diff --git a/sdk/python/flet/auth/oauth_provider.py b/sdk/python/flet/auth/oauth_provider.py index 862f0f49d..8eb029b3e 100644 --- a/sdk/python/flet/auth/oauth_provider.py +++ b/sdk/python/flet/auth/oauth_provider.py @@ -35,5 +35,11 @@ def _name(self): def _fetch_groups(self, access_token: str) -> List[Group]: return [] + async def _fetch_groups_async(self, access_token: str) -> List[Group]: + return [] + def _fetch_user(self, access_token: str) -> Optional[User]: return None + + async def _fetch_user_async(self, access_token: str) -> Optional[User]: + return None diff --git a/sdk/python/flet/auth/providers/github_oauth_provider.py b/sdk/python/flet/auth/providers/github_oauth_provider.py index 4a9842293..ad3083ad1 100644 --- a/sdk/python/flet/auth/providers/github_oauth_provider.py +++ b/sdk/python/flet/auth/providers/github_oauth_provider.py @@ -1,12 +1,14 @@ import json from typing import List, Optional -import requests +import httpx from flet.auth.group import Group from flet.auth.oauth_provider import OAuthProvider from flet.auth.user import User +from flet.version import version + class GitHubOAuthProvider(OAuthProvider): def __init__(self, client_id: str, client_secret: str, redirect_url: str) -> None: @@ -21,9 +23,25 @@ def __init__(self, client_id: str, client_secret: str, redirect_url: str) -> Non ) def _fetch_groups(self, access_token: str) -> List[Group]: - headers = {"Authorization": "Bearer {}".format(access_token)} + with httpx.Client(follow_redirects=True) as client: + teams_resp = client.send(self.__get_user_teams_request(access_token)) + return self.__complete_fetch_groups(teams_resp) + + async def _fetch_groups_async(self, access_token: str) -> List[Group]: + async with httpx.AsyncClient(follow_redirects=True) as client: + teams_resp = await client.send(self.__get_user_teams_request(access_token)) + return self.__complete_fetch_groups(teams_resp) + + def __get_user_teams_request(self, access_token): + return httpx.Request( + "GET", + "https://api.github.com/user/teams", + headers=self.__get_client_headers(access_token), + ) + + def __complete_fetch_groups(self, teams_resp): + teams_resp.raise_for_status() groups = [] - teams_resp = requests.get("https://api.github.com/user/teams", headers=headers) tj = json.loads(teams_resp.text) for t in tj: groups.append( @@ -35,13 +53,46 @@ def _fetch_groups(self, access_token: str) -> List[Group]: return groups def _fetch_user(self, access_token: str) -> Optional[User]: - headers = {"Authorization": "Bearer {}".format(access_token)} - user_resp = requests.get("https://api.github.com/user", headers=headers) + user_req, emails_req = self.__get_user_details_requests(access_token) + with httpx.Client(follow_redirects=True) as client: + user_resp = client.send(user_req) + emails_resp = client.send(emails_req) + return self.__complete_fetch_user_details(user_resp, emails_resp) + + async def _fetch_user_async(self, access_token: str) -> Optional[User]: + user_req, emails_req = self.__get_user_details_requests(access_token) + async with httpx.AsyncClient(follow_redirects=True) as client: + user_resp = await client.send(user_req) + emails_resp = await client.send(emails_req) + return self.__complete_fetch_user_details(user_resp, emails_resp) + + def __get_user_details_requests(self, access_token): + return ( + httpx.Request( + "GET", + "https://api.github.com/user", + headers=self.__get_client_headers(access_token), + ), + httpx.Request( + "GET", + "https://api.github.com/user/emails", + headers=self.__get_client_headers(access_token), + ), + ) + + def __complete_fetch_user_details(self, user_resp, emails_resp): + user_resp.raise_for_status() + emails_resp.raise_for_status() uj = json.loads(user_resp.text) - email_resp = requests.get("https://api.github.com/user/emails", headers=headers) - ej = json.loads(email_resp.text) + ej = json.loads(emails_resp.text) for e in ej: if e["primary"]: uj["email"] = e["email"] break return User(uj, id=str(uj["id"])) + + def __get_client_headers(self, access_token): + return { + "Authorization": "Bearer {}".format(access_token), + "User-Agent": "Flet/{}".format(version), + } diff --git a/sdk/python/flet/callable_control.py b/sdk/python/flet/callable_control.py index 8daccc3dd..e66abc013 100644 --- a/sdk/python/flet/callable_control.py +++ b/sdk/python/flet/callable_control.py @@ -1,7 +1,8 @@ +import asyncio import dataclasses import json import threading -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union from beartype import beartype @@ -37,8 +38,10 @@ def __init__( ) self.__call_counter = 0 - self.__calls: Dict[int, threading.Event] = {} - self.__results: Dict[threading.Event, tuple[Optional[str], Optional[str]]] = {} + self.__calls: Dict[int, Union[threading.Event, asyncio.Event]] = {} + self.__results: Dict[ + Union[threading.Event, asyncio.Event], tuple[Optional[str], Optional[str]] + ] = {} self._add_event_handler("method_result", self._on_result) def _call_method(self, name: str, params: List[str], wait_for_result=True) -> Any: @@ -68,6 +71,39 @@ def _call_method(self, name: str, params: List[str], wait_for_result=True) -> An return None return json.loads(result) + async def _call_method_async( + self, name: str, params: List[str], wait_for_result=True + ) -> Any: + m = ControlMethodCall(i=self.__call_counter, n=name, p=params) + self.__call_counter += 1 + self._set_attr_json("method", m) + + evt: Optional[asyncio.Event] = None + if wait_for_result: + evt = asyncio.Event() + self.__calls[m.i] = evt + await self.update_async() + + if not wait_for_result: + return + + assert evt is not None + + try: + await asyncio.wait_for(evt.wait(), timeout=5) + except TimeoutError: + del self.__calls[m.i] + raise Exception( + f"Timeout waiting for {self.__class__.__name__}.{name}({params}) method call" + ) + + result, err = self.__results.pop(evt) + if err != None: + raise Exception(err) + if result == None: + return None + return json.loads(result) + def _on_result(self, e): d = json.loads(e.data) result = ControlMethodResults(**d) diff --git a/sdk/python/flet/cli/commands/run.py b/sdk/python/flet/cli/commands/run.py index 7a81c43a3..944e2f425 100644 --- a/sdk/python/flet/cli/commands/run.py +++ b/sdk/python/flet/cli/commands/run.py @@ -8,7 +8,7 @@ import threading import time from flet.cli.commands.base import BaseCommand -from flet.flet import open_flet_view +from flet.flet import close_flet_view, open_flet_view from flet.utils import get_free_tcp_port, is_windows, open_in_browser from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer @@ -97,12 +97,7 @@ def handle(self, options: argparse.Namespace) -> None: except KeyboardInterrupt: pass - if my_event_handler.fvp is not None and not is_windows(): - try: - logging.debug(f"Flet View process {my_event_handler.fvp.pid}") - os.kill(my_event_handler.fvp.pid + 1, signal.SIGKILL) - except: - pass + close_flet_view(my_event_handler.pid_file) my_observer.stop() my_observer.join() @@ -118,6 +113,7 @@ def __init__(self, args, script_path, port, web, hidden) -> None: self.last_time = time.time() self.is_running = False self.fvp = None + self.pid_file = None self.page_url_prefix = f"PAGE_URL_{time.time()}" self.page_url = None self.terminate = threading.Event() @@ -165,7 +161,7 @@ def print_output(self, p): print(line) def open_flet_view_and_wait(self): - self.fvp = open_flet_view(self.page_url, self.hidden) + self.fvp, self.pid_file = open_flet_view(self.page_url, self.hidden) self.fvp.wait() self.p.kill() self.terminate.set() diff --git a/sdk/python/flet/client_storage.py b/sdk/python/flet/client_storage.py index 90c07b93a..1df41f342 100644 --- a/sdk/python/flet/client_storage.py +++ b/sdk/python/flet/client_storage.py @@ -16,6 +16,15 @@ def set(self, key: str, value: Any) -> bool: == "true" ) + async def set_async(self, key: str, value: Any) -> bool: + jv = self.__page._convert_attr_json(value) + assert jv is not None + return ( + await self.__page.invoke_method_async( + "clientStorage:set", {"key": key, "value": jv}, wait_for_result=True + ) + ) == "true" + def get(self, key: str): jv = self.__page.invoke_method( "clientStorage:get", {"key": key}, wait_for_result=True @@ -24,6 +33,14 @@ def get(self, key: str): return json.loads(json.loads(jv)) return None + async def get_async(self, key: str): + jv = await self.__page.invoke_method_async( + "clientStorage:get", {"key": key}, wait_for_result=True + ) + if jv: + return json.loads(json.loads(jv)) + return None + def contains_key(self, key: str) -> bool: return ( self.__page.invoke_method( @@ -32,6 +49,14 @@ def contains_key(self, key: str) -> bool: == "true" ) + async def contains_key_async(self, key: str) -> bool: + return ( + await self.__page.invoke_method_async( + "clientStorage:containskey", {"key": key}, wait_for_result=True + ) + == "true" + ) + def remove(self, key: str) -> bool: return ( self.__page.invoke_method( @@ -40,6 +65,13 @@ def remove(self, key: str) -> bool: == "true" ) + async def remove_async(self, key: str) -> bool: + return ( + await self.__page.invoke_method_async( + "clientStorage:remove", {"key": key}, wait_for_result=True + ) + ) == "true" + def get_keys(self, key_prefix: str) -> List[str]: jr = self.__page.invoke_method( "clientStorage:getkeys", {"key_prefix": key_prefix}, wait_for_result=True @@ -47,8 +79,23 @@ def get_keys(self, key_prefix: str) -> List[str]: assert jr is not None return json.loads(jr) + async def get_keys_async(self, key_prefix: str) -> List[str]: + jr = await self.__page.invoke_method_async( + "clientStorage:getkeys", {"key_prefix": key_prefix}, wait_for_result=True + ) + assert jr is not None + return json.loads(jr) + def clear(self) -> bool: return ( self.__page.invoke_method("clientStorage:clear", wait_for_result=True) == "true" ) + + async def clear_async(self) -> bool: + return ( + await self.__page.invoke_method_async( + "clientStorage:clear", wait_for_result=True + ) + == "true" + ) diff --git a/sdk/python/flet/clipboard.py b/sdk/python/flet/clipboard.py index ffd0d87bf..2fad832d3 100644 --- a/sdk/python/flet/clipboard.py +++ b/sdk/python/flet/clipboard.py @@ -36,5 +36,11 @@ def _is_isolated(self): def set_data(self, data: str): self._call_method("set_data", [data], wait_for_result=False) + async def set_data_async(self, data: str): + await self._call_method_async("set_data", [data], wait_for_result=False) + def get_data(self) -> str: return self._call_method("get_data", []) + + async def get_data_async(self) -> str: + return await self._call_method_async("get_data", []) diff --git a/sdk/python/flet/column.py b/sdk/python/flet/column.py index e610b6454..ab8071ee6 100644 --- a/sdk/python/flet/column.py +++ b/sdk/python/flet/column.py @@ -141,7 +141,11 @@ def _get_children(self): return self.__controls def clean(self): - Control.clean(self) + super().clean() + self.__controls.clear() + + async def clean_async(self): + await super().clean_async() self.__controls.clear() # tight diff --git a/sdk/python/flet/connection.py b/sdk/python/flet/connection.py index 20f3444c5..8d379c36c 100644 --- a/sdk/python/flet/connection.py +++ b/sdk/python/flet/connection.py @@ -1,126 +1,33 @@ -import logging -import threading -import uuid - -from flet.protocol import * +from typing import List, Optional +from flet.protocol import Command from flet.pubsub import PubSubHub -from flet.reconnecting_websocket import ReconnectingWebSocket class Connection: - def __init__(self, ws: ReconnectingWebSocket): - self._ws = ws - self._ws.on_message = self._on_message - self._ws_callbacks = {} - self._on_event = None - self._on_session_created = None - self.host_client_id: Optional[str] = None - self.page_name: Optional[str] = None + def __init__(self): + self.page_name: str = "" self.page_url: Optional[str] = None self.sessions = {} self.pubsubhub = PubSubHub() - @property - def on_event(self): - return self._on_event - - @on_event.setter - def on_event(self, handler): - self._on_event = handler - - @property - def on_session_created(self): - return self._on_session_created - - @on_session_created.setter - def on_session_created(self, handler): - self._on_session_created = handler - - def _on_message(self, data): - logging.debug(f"_on_message: {data}") - msg_dict = json.loads(data) - msg = Message(**msg_dict) - if msg.id: - # callback - evt = self._ws_callbacks[msg.id][0] - self._ws_callbacks[msg.id] = (None, msg.payload) - evt.set() - elif msg.action == Actions.PAGE_EVENT_TO_HOST: - if self._on_event is not None: - th = threading.Thread( - target=self._on_event, - args=( - self, - PageEventPayload(**msg.payload), - ), - daemon=True, - ) - th.start() - # self._on_event(self, PageEventPayload(**msg.payload)) - elif msg.action == Actions.SESSION_CREATED: - if self._on_session_created is not None: - th = threading.Thread( - target=self._on_session_created, - args=( - self, - PageSessionCreatedPayload(**msg.payload), - ), - daemon=True, - ) - th.start() - else: - # it's something else - print(msg.payload) - - def register_host_client( - self, - host_client_id: Optional[str], - page_name: str, - is_app: bool, - update: bool, - auth_token: Optional[str], - permissions: Optional[str], - ): - payload = RegisterHostClientRequestPayload( - host_client_id, page_name, is_app, update, auth_token, permissions - ) - response = self._send_message_with_result(Actions.REGISTER_HOST_CLIENT, payload) - return RegisterHostClientResponsePayload(**response) - def send_command(self, session_id: str, command: Command): - assert self.page_name is not None - payload = PageCommandRequestPayload(self.page_name, session_id, command) - response = self._send_message_with_result( - Actions.PAGE_COMMAND_FROM_HOST, payload - ) - result = PageCommandResponsePayload(**response) - if result.error: - raise Exception(result.error) - return result + raise NotImplementedError() + + async def send_command_async(self, session_id: str, command: Command): + raise NotImplementedError() def send_commands(self, session_id: str, commands: List[Command]): - assert self.page_name is not None - payload = PageCommandsBatchRequestPayload(self.page_name, session_id, commands) - response = self._send_message_with_result( - Actions.PAGE_COMMANDS_BATCH_FROM_HOST, payload - ) - result = PageCommandsBatchResponsePayload(**response) - if result.error: - raise Exception(result.error) - return result + raise NotImplementedError() - def _send_message_with_result(self, action_name, payload): - msg_id = uuid.uuid4().hex - msg = Message(msg_id, action_name, payload) - j = json.dumps(msg, cls=CommandEncoder, separators=(",", ":")) - logging.debug(f"_send_message_with_result: {j}") - evt = threading.Event() - self._ws_callbacks[msg_id] = (evt, None) - self._ws.send(j) - evt.wait() - return self._ws_callbacks.pop(msg_id)[1] + async def send_commands_async(self, session_id: str, commands: List[Command]): + raise NotImplementedError() - def close(self): - logging.debug("Closing connection...") - if self._ws is not None: - self._ws.close() + def _get_ws_url(self, server: str): + url = server.rstrip("/") + if server.startswith("https://"): + url = url.replace("https://", "wss://") + elif server.startswith("http://"): + url = url.replace("http://", "ws://") + else: + url = "ws://" + url + return url + "/ws" diff --git a/sdk/python/flet/constants.py b/sdk/python/flet/constants.py index df119b68a..8e082df76 100644 --- a/sdk/python/flet/constants.py +++ b/sdk/python/flet/constants.py @@ -1,4 +1,3 @@ INDEX_PAGE = "p/index" -HOSTED_SERVICE_URL = "https://app.flet.dev" CONNECT_TIMEOUT_SECONDS = 30 ZERO_SESSION = "0" diff --git a/sdk/python/flet/container.py b/sdk/python/flet/container.py index d3fb7d2db..20f1734ae 100644 --- a/sdk/python/flet/container.py +++ b/sdk/python/flet/container.py @@ -150,7 +150,7 @@ def convert_container_tap_event_data(e): return ContainerTapEvent(**d) self.__on_click = EventHandler(convert_container_tap_event_data) - self._add_event_handler("click", self.__on_click.handler) + self._add_event_handler("click", self.__on_click.get_handler()) self.content = content self.padding = padding diff --git a/sdk/python/flet/control.py b/sdk/python/flet/control.py index a49a9c6ce..eb2d641fd 100644 --- a/sdk/python/flet/control.py +++ b/sdk/python/flet/control.py @@ -1,6 +1,5 @@ import datetime as dt import json -import threading from difflib import SequenceMatcher from typing import TYPE_CHECKING, Any, Union @@ -51,7 +50,6 @@ def __init__( self.__data: Any = None self.data = data self.__event_handlers = {} - self._lock = threading.Lock() if ref: ref.current = self @@ -67,9 +65,15 @@ def _before_build_command(self): def did_mount(self): pass + async def did_mount_async(self): + pass + def will_unmount(self): pass + async def will_unmount_async(self): + pass + def _get_children(self): return [] @@ -254,20 +258,24 @@ def data(self, value): # public methods def update(self): - if not self.__page: - raise Exception("Control must be added to the page first.") + assert self.__page, "Control must be added to the page first." self.__page.update(self) + async def update_async(self): + assert self.__page, "Control must be added to the page first." + await self.__page.update_async(self) + def clean(self): - with self._lock: - self._previous_children.clear() - assert self.__page is not None - assert self.uid is not None - for child in self._get_children(): - self._remove_control_recursively(self.__page.index, child) - return self.__page._send_command("clean", [self.uid]) - - def build_update_commands(self, index, added_controls, commands, isolated=False): + assert self.__page, "Control must be added to the page first." + self.__page._clean(self) + + async def clean_async(self): + assert self.__page, "Control must be added to the page first." + await self.__page._clean_async(self) + + def build_update_commands( + self, index, commands, added_controls, removed_controls, isolated=False + ): update_cmd = self._build_command(update=True) if len(update_cmd.attrs) > 0: @@ -316,7 +324,9 @@ def build_update_commands(self, index, added_controls, commands, isolated=False) replaced = True break i += 1 - self._remove_control_recursively(index, ctrl) + removed_controls.extend( + self._remove_control_recursively(index, ctrl) + ) if not replaced: ids.append(ctrl.__uid) if len(ids) > 0: @@ -343,7 +353,11 @@ def build_update_commands(self, index, added_controls, commands, isolated=False) for h in previous_ints[a1:a2]: ctrl = hashes[h] ctrl.build_update_commands( - index, added_controls, commands, isolated=ctrl._is_isolated() + index, + commands, + added_controls, + removed_controls, + isolated=ctrl._is_isolated(), ) n += 1 elif tag == "insert": @@ -368,13 +382,15 @@ def build_update_commands(self, index, added_controls, commands, isolated=False) self.__previous_children.extend(current_children) def _remove_control_recursively(self, index, control): + removed_controls = [control] for child in control._get_children(): - self._remove_control_recursively(index, child) + removed_controls.extend(self._remove_control_recursively(index, child)) if control.__uid in index: - control.will_unmount() del index[control.__uid] + return removed_controls + # private methods def _build_add_commands(self, indent=0, index=None, added_controls=None): diff --git a/sdk/python/flet/datatable.py b/sdk/python/flet/datatable.py index 98648f67f..36d9e113e 100644 --- a/sdk/python/flet/datatable.py +++ b/sdk/python/flet/datatable.py @@ -44,7 +44,7 @@ def __init__( self.__on_sort = EventHandler( lambda e: DataColumnSortEvent(**json.loads(e.data)) ) - self._add_event_handler("sort", self.__on_sort.handler) + self._add_event_handler("sort", self.__on_sort.get_handler()) self.label = label self.numeric = numeric @@ -116,7 +116,7 @@ def __init__( Control.__init__(self, ref=ref) self.__on_tap_down = EventHandler(lambda e: TapEvent(**json.loads(e.data))) - self._add_event_handler("tap_down", self.__on_tap_down.handler) + self._add_event_handler("tap_down", self.__on_tap_down.get_handler()) self.content = content self.on_double_tap = on_double_tap diff --git a/sdk/python/flet/drag_target.py b/sdk/python/flet/drag_target.py index 2dc9abecb..79c18b4e1 100644 --- a/sdk/python/flet/drag_target.py +++ b/sdk/python/flet/drag_target.py @@ -14,7 +14,7 @@ class DragTarget(Control): A control that completes drag operation when a `Draggable` widget is dropped. When a draggable is dragged on top of a drag target, the drag target is asked whether it will accept the data the draggable is carrying. The drag target will accept incoming drag if it belongs to the same group as draggable. If the user does drop the draggable on top of the drag target (and the drag target has indicated that it will accept the draggable's data), then the drag target is asked to accept the draggable's data. - + Example: ``` import flet as ft @@ -102,6 +102,7 @@ def drag_leave(e): Online docs: https://flet.dev/docs/controls/dragtarget """ + def __init__( self, ref: Optional[Ref] = None, @@ -131,7 +132,7 @@ def convert_accept_event_data(e): return DragTargetAcceptEvent(**d) self.__on_accept = EventHandler(convert_accept_event_data) - self._add_event_handler("accept", self.__on_accept.handler) + self._add_event_handler("accept", self.__on_accept.get_handler()) self.__content: Optional[Control] = None diff --git a/sdk/python/flet/dropdown.py b/sdk/python/flet/dropdown.py index 96f9b2f60..1dd9f43ed 100644 --- a/sdk/python/flet/dropdown.py +++ b/sdk/python/flet/dropdown.py @@ -210,6 +210,10 @@ def focus(self): self._set_attr_json("focus", FocusData()) self.update() + async def focus_async(self): + self._set_attr_json("focus", FocusData()) + await self.update_async() + # options @property def options(self): diff --git a/sdk/python/flet/event_handler.py b/sdk/python/flet/event_handler.py index 769bb1c2d..1f0c37eb0 100644 --- a/sdk/python/flet/event_handler.py +++ b/sdk/python/flet/event_handler.py @@ -1,9 +1,18 @@ +from flet.utils import is_asyncio + + class EventHandler: def __init__(self, result_converter=None) -> None: self.__handlers = {} self.__result_converter = result_converter - def handler(self, e): + def get_handler(self): + if is_asyncio(): + return self.__async_handler + else: + return self.__sync_handler + + def __sync_handler(self, e): for h in self.__handlers.keys(): if self.__result_converter is not None: r = self.__result_converter(e) @@ -17,6 +26,20 @@ def handler(self, e): else: h(e) + async def __async_handler(self, e): + for h in self.__handlers.keys(): + if self.__result_converter is not None: + r = self.__result_converter(e) + if r is not None: + r.target = e.target + r.name = e.name + r.data = e.data + r.control = e.control + r.page = e.page + await h(r) + else: + await h(e) + def subscribe(self, handler): if handler is not None: self.__handlers[handler] = True diff --git a/sdk/python/flet/file_picker.py b/sdk/python/flet/file_picker.py index 63e903c5d..b22130f13 100644 --- a/sdk/python/flet/file_picker.py +++ b/sdk/python/flet/file_picker.py @@ -65,7 +65,7 @@ class FilePickerUploadEvent(ControlEvent): class FilePicker(Control): """ A control that allows you to use the native file explorer to pick single or multiple files, with extensions filtering support and upload. - + Example: ``` import flet as ft @@ -104,6 +104,7 @@ def pick_files_result(e: ft.FilePickerResultEvent): Online docs: https://flet.dev/docs/controls/filepicker """ + def __init__( self, ref: Optional[Ref] = None, @@ -131,14 +132,14 @@ def convert_result_event_data(e): return self.__result self.__on_result = EventHandler(convert_result_event_data) - self._add_event_handler("result", self.__on_result.handler) + self._add_event_handler("result", self.__on_result.get_handler()) def convert_upload_event_data(e): d = json.loads(e.data) return FilePickerUploadEvent(**d) self.__on_upload = EventHandler(convert_upload_event_data) - self._add_event_handler("upload", self.__on_upload.handler) + self._add_event_handler("upload", self.__on_upload.get_handler()) self.__result: Optional[FilePickerResultEvent] = None self.__upload: List[FilePickerUploadFile] = [] @@ -170,6 +171,22 @@ def pick_files( self.allow_multiple = allow_multiple self.update() + async def pick_files_async( + self, + dialog_title: Optional[str] = None, + initial_directory: Optional[str] = None, + file_type: FilePickerFileType = FilePickerFileType.ANY, + allowed_extensions: Optional[List[str]] = None, + allow_multiple: Optional[bool] = False, + ): + self.state = "pickFiles" + self.dialog_title = dialog_title + self.initial_directory = initial_directory + self.file_type = file_type + self.allowed_extensions = allowed_extensions + self.allow_multiple = allow_multiple + await self.update_async() + def save_file( self, dialog_title: Optional[str] = None, @@ -186,6 +203,22 @@ def save_file( self.allowed_extensions = allowed_extensions self.update() + async def save_file_async( + self, + dialog_title: Optional[str] = None, + file_name: Optional[str] = None, + initial_directory: Optional[str] = None, + file_type: FilePickerFileType = FilePickerFileType.ANY, + allowed_extensions: Optional[List[str]] = None, + ): + self.state = "saveFile" + self.dialog_title = dialog_title + self.file_name = file_name + self.initial_directory = initial_directory + self.file_type = file_type + self.allowed_extensions = allowed_extensions + await self.update_async() + def get_directory_path( self, dialog_title: Optional[str] = None, @@ -196,10 +229,24 @@ def get_directory_path( self.initial_directory = initial_directory self.update() + async def get_directory_path_async( + self, + dialog_title: Optional[str] = None, + initial_directory: Optional[str] = None, + ): + self.state = "getDirectoryPath" + self.dialog_title = dialog_title + self.initial_directory = initial_directory + await self.update_async() + def upload(self, files: List[FilePickerUploadFile]): self.__upload = files self.update() + async def upload_async(self, files: List[FilePickerUploadFile]): + self.__upload = files + await self.update_async() + # state @property def state(self) -> Optional[FilePickerState]: diff --git a/sdk/python/flet/flet.py b/sdk/python/flet/flet.py index dd1b92748..607b2fd44 100644 --- a/sdk/python/flet/flet.py +++ b/sdk/python/flet/flet.py @@ -1,4 +1,5 @@ -import json +import asyncio +import inspect import logging import os import signal @@ -11,13 +12,12 @@ import urllib.request import zipfile from pathlib import Path -from time import sleep -from flet import constants, version -from flet.connection import Connection +from flet import version +from flet.async_connection import AsyncConnection +from flet.sync_connection import SyncConnection from flet.event import Event from flet.page import Page -from flet.reconnecting_websocket import ReconnectingWebSocket from flet.utils import ( get_arch, get_current_script_dir, @@ -28,6 +28,7 @@ is_macos, is_windows, open_in_browser, + random_string, safe_tar_extractall, which, ) @@ -47,64 +48,135 @@ WebRenderer = Literal[None, "auto", "html", "canvaskit"] -def page( +def app( + target, name="", host=None, port=0, - permissions=None, - view: AppViewer = WEB_BROWSER, + view: AppViewer = FLET_APP, assets_dir=None, upload_dir=None, web_renderer="canvaskit", route_url_strategy="hash", + auth_token=None, ): - conn = _connect_internal( + + if inspect.iscoroutinefunction(target): + asyncio.run( + app_async( + target=target, + name=name, + host=host, + port=port, + view=view, + assets_dir=assets_dir, + upload_dir=upload_dir, + web_renderer=web_renderer, + route_url_strategy=route_url_strategy, + auth_token=auth_token, + ) + ) + else: + __app_sync( + target=target, + name=name, + host=host, + port=port, + view=view, + assets_dir=assets_dir, + upload_dir=upload_dir, + web_renderer=web_renderer, + route_url_strategy=route_url_strategy, + auth_token=auth_token, + ) + + +def __app_sync( + target, + name="", + host=None, + port=0, + view: AppViewer = FLET_APP, + assets_dir=None, + upload_dir=None, + web_renderer="canvaskit", + route_url_strategy="hash", + auth_token=None, +): + conn = __connect_internal_sync( page_name=name, host=host, port=port, - is_app=False, - permissions=permissions, + auth_token=auth_token, + session_handler=target, assets_dir=assets_dir, upload_dir=upload_dir, web_renderer=web_renderer, route_url_strategy=route_url_strategy, ) + url_prefix = os.getenv("FLET_DISPLAY_URL_PREFIX") if url_prefix is not None: print(url_prefix, conn.page_url) else: - logging.info(f"Page URL: {conn.page_url}") + logging.info(f"App URL: {conn.page_url}") - page = Page(conn, constants.ZERO_SESSION) - conn.sessions[constants.ZERO_SESSION] = page + terminate = threading.Event() + + def exit_gracefully(signum, frame): + logging.debug("Gracefully terminating Flet app...") + terminate.set() - if view == WEB_BROWSER: - open_in_browser(conn.page_url) + signal.signal(signal.SIGINT, exit_gracefully) + signal.signal(signal.SIGTERM, exit_gracefully) - return page + logging.info("Connected to Flet app and handling user sessions...") + fvp = None + pid_file = None -def app( + if ( + (view == FLET_APP or view == FLET_APP_HIDDEN) + and not is_linux_server() + and url_prefix is None + ): + fvp, pid_file = open_flet_view(conn.page_url, view == FLET_APP_HIDDEN) + try: + fvp.wait() + except (Exception) as e: + pass + else: + if view == WEB_BROWSER and url_prefix is None: + open_in_browser(conn.page_url) + try: + while True: + if terminate.wait(1): + break + except KeyboardInterrupt: + pass + + conn.close() + close_flet_view(pid_file) + + +async def app_async( + target, name="", host=None, port=0, - target=None, - permissions=None, view: AppViewer = FLET_APP, assets_dir=None, upload_dir=None, web_renderer="canvaskit", route_url_strategy="hash", + auth_token=None, ): - if target is None: - raise Exception("target argument is not specified") - conn = _connect_internal( + conn = await __connect_internal_async( page_name=name, host=host, port=port, - is_app=True, - permissions=permissions, + auth_token=auth_token, session_handler=target, assets_dir=assets_dir, upload_dir=upload_dir, @@ -118,7 +190,7 @@ def app( else: logging.info(f"App URL: {conn.page_url}") - terminate = threading.Event() + terminate = asyncio.Event() def exit_gracefully(signum, frame): logging.debug("Gracefully terminating Flet app...") @@ -130,77 +202,66 @@ def exit_gracefully(signum, frame): logging.info("Connected to Flet app and handling user sessions...") fvp = None + pid_file = None if ( (view == FLET_APP or view == FLET_APP_HIDDEN) and not is_linux_server() and url_prefix is None ): - fvp = open_flet_view(conn.page_url, view == FLET_APP_HIDDEN) + fvp, pid_file = await open_flet_view_async( + conn.page_url, view == FLET_APP_HIDDEN + ) try: - fvp.wait() + await fvp.wait() except (Exception) as e: pass else: if view == WEB_BROWSER and url_prefix is None: open_in_browser(conn.page_url) try: - while True: - if terminate.wait(1): - break + await terminate.wait() except KeyboardInterrupt: pass - conn.close() + await conn.close() + close_flet_view(pid_file) - if fvp is not None and not is_windows(): + +def close_flet_view(pid_file): + if pid_file is not None and os.path.exists(pid_file): try: - logging.debug(f"Flet View process {fvp.pid}") - os.kill(fvp.pid + 1, signal.SIGKILL) + with open(pid_file) as f: + fvp_pid = int(f.read()) + logging.debug(f"Flet View process {fvp_pid}") + os.kill(fvp_pid, signal.SIGKILL) except: pass + finally: + os.remove(pid_file) -def _connect_internal( - page_name=None, +def __connect_internal_sync( + page_name, host=None, port=0, - is_app=False, - update=False, - share=False, server=None, - token=None, - permissions=None, + auth_token=None, session_handler=None, assets_dir=None, upload_dir=None, web_renderer=None, route_url_strategy=None, ): - if share and server is None: - server = constants.HOSTED_SERVICE_URL - elif server is None: - # local mode - env_port = os.getenv("FLET_SERVER_PORT") - if env_port is not None and env_port: - port = env_port - - # page with a custom port starts detached process - attached = False if not is_app and port != 0 else True - - server_ip = host if host not in [None, "", "*"] else "127.0.0.1" - port = _start_flet_server( + if server is None: + server = __start_flet_server( host, port, - attached, assets_dir, upload_dir, web_renderer, route_url_strategy, ) - server = f"http://{server_ip}:{port}" - - connected = threading.Event() def on_event(conn, e): if e.sessionID in conn.sessions: @@ -213,6 +274,7 @@ def on_event(conn, e): def on_session_created(conn, session_data): page = Page(conn, session_data.sessionID) + page.fetch_page_details() conn.sessions[session_data.sessionID] = page logging.info(f"Session started: {session_data.sessionID}") try: @@ -225,57 +287,90 @@ def on_session_created(conn, session_data): ) page.error(f"There was an error while processing your request: {e}") - ws_url = _get_ws_url(server) - ws = ReconnectingWebSocket(ws_url) - conn = Connection(ws) - conn.on_event = on_event + conn = SyncConnection( + server_address=server, + page_name=page_name, + token=auth_token, + on_event=on_event, + on_session_created=on_session_created, + ) + conn.connect() + return conn - if session_handler is not None: - conn.on_session_created = on_session_created - def _on_ws_connect(): - if conn.page_name is None: - conn.page_name = page_name - assert conn.page_name is not None - result = conn.register_host_client( - conn.host_client_id, conn.page_name, is_app, update, token, permissions - ) - conn.host_client_id = result.hostClientID - conn.page_name = result.pageName - conn.page_url = server.rstrip("/") - if conn.page_name != constants.INDEX_PAGE: - assert conn.page_url is not None - conn.page_url += f"/{conn.page_name}" - connected.set() - - def _on_ws_failed_connect(): - logging.info(f"Failed to connect: {ws_url}") - # if is_localhost_url(ws_url): - # _start_flet_server() - - ws.on_connect = _on_ws_connect - ws.on_failed_connect = _on_ws_failed_connect - ws.connect() - for n in range(0, constants.CONNECT_TIMEOUT_SECONDS): - if not connected.is_set(): - sleep(1) - if not connected.is_set(): - ws.close() - raise Exception( - f"Could not connected to Flet server in {constants.CONNECT_TIMEOUT_SECONDS} seconds." +async def __connect_internal_async( + page_name, + host=None, + port=0, + server=None, + auth_token=None, + session_handler=None, + assets_dir=None, + upload_dir=None, + web_renderer=None, + route_url_strategy=None, +): + if server is None: + server = __start_flet_server( + host, + port, + assets_dir, + upload_dir, + web_renderer, + route_url_strategy, ) + async def on_event(e): + if e.sessionID in conn.sessions: + await conn.sessions[e.sessionID].on_event_async( + Event(e.eventTarget, e.eventName, e.eventData) + ) + if e.eventTarget == "page" and e.eventName == "close": + logging.info(f"Session closed: {e.sessionID}") + del conn.sessions[e.sessionID] + + async def on_session_created(session_data): + page = Page(conn, session_data.sessionID) + await page.fetch_page_details_async() + conn.sessions[session_data.sessionID] = page + logging.info(f"Session started: {session_data.sessionID}") + try: + assert session_handler is not None + await session_handler(page) + except Exception as e: + print( + f"Unhandled error processing page session {page.session_id}:", + traceback.format_exc(), + ) + await page.error_async( + f"There was an error while processing your request: {e}" + ) + + conn = AsyncConnection( + server_address=server, + page_name=page_name, + auth_token=auth_token, + on_event=on_event, + on_session_created=on_session_created, + ) + await conn.connect() return conn -def _start_flet_server( - host, port, attached, assets_dir, upload_dir, web_renderer, route_url_strategy +def __start_flet_server( + host, port, assets_dir, upload_dir, web_renderer, route_url_strategy ): + # local mode + env_port = os.getenv("FLET_SERVER_PORT") + if env_port is not None and env_port: + port = env_port + + server_ip = host if host not in [None, "", "*"] else "127.0.0.1" + if port == 0: port = get_free_tcp_port() logging.info(f"Starting local Flet Server on port {port}...") - logging.info(f"Attached process: {attached}") fletd_exe = "fletd.exe" if is_windows() else "fletd" @@ -289,7 +384,7 @@ def _start_flet_server( fletd_path = which(fletd_exe) if not fletd_path: # download flet from GitHub (python module developer mode) - fletd_path = _download_fletd() + fletd_path = __download_fletd() else: logging.info(f"Flet Server found in PATH") @@ -337,13 +432,7 @@ def _start_flet_server( creationflags = 0 start_new_session = False - if attached: - args.append("--attached") - else: - if is_windows(): - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP - else: - start_new_session = True + args.append("--attached") log_level = logging.getLogger().getEffectiveLevel() if log_level == logging.CRITICAL: @@ -368,14 +457,30 @@ def _start_flet_server( startupinfo=startupinfo, ) - return port + return f"http://{server_ip}:{port}" def open_flet_view(page_url, hidden): + args, flet_env, pid_file = __locate_and_unpack_flet_view(page_url, hidden) + return subprocess.Popen(args, env=flet_env), pid_file + + +async def open_flet_view_async(page_url, hidden): + args, flet_env, pid_file = __locate_and_unpack_flet_view(page_url, hidden) + return ( + await asyncio.create_subprocess_exec(args[0], *args[1:], env=flet_env), + pid_file, + ) + + +def __locate_and_unpack_flet_view(page_url, hidden): logging.info(f"Starting Flet View app...") args = [] + # pid file - Flet client writes its process ID to this file + pid_file = str(Path(tempfile.gettempdir()).joinpath(random_string(20))) + if is_windows(): flet_exe = "flet.exe" temp_flet_dir = Path.home().joinpath(".flet", "bin", f"flet-{version.version}") @@ -395,32 +500,38 @@ def open_flet_view(page_url, hidden): logging.info(f"Flet View found in PATH: {flet_path}") else: if not temp_flet_dir.exists(): - zip_file = _download_flet_client("flet-windows.zip") + zip_file = __download_flet_client("flet-windows.zip") logging.info(f"Extracting flet.exe from archive to {temp_flet_dir}") temp_flet_dir.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(zip_file, "r") as zip_arch: zip_arch.extractall(str(temp_flet_dir)) flet_path = str(temp_flet_dir.joinpath("flet", flet_exe)) - args = [flet_path, page_url] + args = [flet_path, page_url, pid_file] elif is_macos(): # build version-specific path to Flet.app temp_flet_dir = Path.home().joinpath(".flet", "bin", f"flet-{version.version}") - # check if flet_view.app exists in a temp directory - if not temp_flet_dir.exists(): - # check if flet.tar.gz exists - gz_filename = "flet-macos-amd64.tar.gz" - tar_file = Path(__file__).parent.joinpath("bin", gz_filename) - if not tar_file.exists(): - tar_file = _download_flet_client(gz_filename) - - logging.info(f"Extracting Flet.app from archive to {temp_flet_dir}") - temp_flet_dir.mkdir(parents=True, exist_ok=True) - with tarfile.open(str(tar_file), "r:gz") as tar_arch: - safe_tar_extractall(tar_arch, str(temp_flet_dir)) + # check if flet.exe is in PATH (flet developer mode) + flet_path = which("flet", sys.argv[0]) + if flet_path and "/Contents/MacOS/" in flet_path: + logging.info(f"Flet.app found in PATH: {flet_path}") + temp_flet_dir = Path(flet_path).parent.parent.parent.parent else: - logging.info(f"Flet View found in: {temp_flet_dir}") + # check if flet_view.app exists in a temp directory + if not temp_flet_dir.exists(): + # check if flet.tar.gz exists + gz_filename = "flet-macos-amd64.tar.gz" + tar_file = Path(__file__).parent.joinpath("bin", gz_filename) + if not tar_file.exists(): + tar_file = __download_flet_client(gz_filename) + + logging.info(f"Extracting Flet.app from archive to {temp_flet_dir}") + temp_flet_dir.mkdir(parents=True, exist_ok=True) + with tarfile.open(str(tar_file), "r:gz") as tar_arch: + safe_tar_extractall(tar_arch, str(temp_flet_dir)) + else: + logging.info(f"Flet View found in: {temp_flet_dir}") app_name = None for f in os.listdir(temp_flet_dir): @@ -428,7 +539,7 @@ def open_flet_view(page_url, hidden): app_name = f assert app_name is not None, f"Application bundle not found in {temp_flet_dir}" app_path = temp_flet_dir.joinpath(app_name) - args = ["open", str(app_path), "-n", "-W", "--args", page_url] + args = ["open", str(app_path), "-n", "-W", "--args", page_url, pid_file] elif is_linux(): # build version-specific path to flet folder temp_flet_dir = Path.home().joinpath(".flet", "bin", f"flet-{version.version}") @@ -439,7 +550,7 @@ def open_flet_view(page_url, hidden): gz_filename = f"flet-linux-{get_arch()}.tar.gz" tar_file = Path(__file__).parent.joinpath("bin", gz_filename) if not tar_file.exists(): - tar_file = _download_flet_client(gz_filename) + tar_file = __download_flet_client(gz_filename) logging.info(f"Extracting Flet from archive to {temp_flet_dir}") temp_flet_dir.mkdir(parents=True, exist_ok=True) @@ -449,29 +560,17 @@ def open_flet_view(page_url, hidden): logging.info(f"Flet View found in: {temp_flet_dir}") app_path = temp_flet_dir.joinpath("flet", "flet") - args = [str(app_path), page_url] + args = [str(app_path), page_url, pid_file] flet_env = {**os.environ} if hidden: flet_env["FLET_HIDE_WINDOW_ON_START"] = "true" - # execute process - return subprocess.Popen(args, env=flet_env) + return args, flet_env, pid_file -def _get_ws_url(server: str): - url = server.rstrip("/") - if server.startswith("https://"): - url = url.replace("https://", "wss://") - elif server.startswith("http://"): - url = url.replace("http://", "ws://") - else: - url = "ws://" + url - return url + "/ws" - - -def _download_fletd(): +def __download_fletd(): ver = version.version flet_exe = "fletd.exe" if is_windows() else "fletd" @@ -505,7 +604,7 @@ def _download_fletd(): return str(temp_fletd_dir.joinpath(flet_exe)) -def _download_flet_client(file_name): +def __download_flet_client(file_name): ver = version.version temp_arch = Path(tempfile.gettempdir()).joinpath(file_name) logging.info(f"Downloading Flet v{ver} to {temp_arch}") @@ -514,21 +613,6 @@ def _download_flet_client(file_name): return str(temp_arch) -# not currently used, but maybe useful in the future -def _get_latest_flet_release(): - releases = json.loads( - urllib.request.urlopen( - f"https://api.github.com/repos/flet-dev/flet/releases?per_page=5" - ) - .read() - .decode() - ) - if len(releases) > 0: - return releases[0]["tag_name"].lstrip("v") - else: - return None - - # Fix: https://bugs.python.org/issue35935 # if _is_windows(): # signal.signal(signal.SIGINT, signal.SIG_DFL) diff --git a/sdk/python/flet/gesture_detector.py b/sdk/python/flet/gesture_detector.py index 2fec4c8e5..9702c5691 100644 --- a/sdk/python/flet/gesture_detector.py +++ b/sdk/python/flet/gesture_detector.py @@ -64,7 +64,7 @@ class GestureDetector(ConstrainedControl): Attempts to recognize gestures that correspond to its non-null callbacks. If this control has a content, it defers to that child control for its sizing behavior. If it does not have a content, it grows to fit the parent instead. - + Example: ``` import flet as ft @@ -106,6 +106,7 @@ def on_pan_update2(e: ft.DragUpdateEvent): Online docs: https://flet.dev/docs/controls/gesturedetector """ + def __init__( self, content: Optional[Control] = None, @@ -201,55 +202,65 @@ def __init__( ) self.__on_tap_down = EventHandler(lambda e: TapEvent(**json.loads(e.data))) - self._add_event_handler("tap_down", self.__on_tap_down.handler) + self._add_event_handler("tap_down", self.__on_tap_down.get_handler()) self.__on_tap_up = EventHandler(lambda e: TapEvent(**json.loads(e.data))) - self._add_event_handler("tap_up", self.__on_tap_up.handler) + self._add_event_handler("tap_up", self.__on_tap_up.get_handler()) self.__on_multi_tap = EventHandler( lambda e: MultiTapEvent(e.data.lower() == "true") ) - self._add_event_handler("multi_tap", self.__on_multi_tap.handler) + self._add_event_handler("multi_tap", self.__on_multi_tap.get_handler()) self.__on_secondary_tap_down = EventHandler( lambda e: TapEvent(**json.loads(e.data)) ) self._add_event_handler( - "secondary_tap_down", self.__on_secondary_tap_down.handler + "secondary_tap_down", self.__on_secondary_tap_down.get_handler() ) self.__on_secondary_tap_up = EventHandler( lambda e: TapEvent(**json.loads(e.data)) ) - self._add_event_handler("secondary_tap_up", self.__on_secondary_tap_up.handler) + self._add_event_handler( + "secondary_tap_up", self.__on_secondary_tap_up.get_handler() + ) self.__on_long_press_start = EventHandler( lambda e: LongPressStartEvent(**json.loads(e.data)) ) - self._add_event_handler("long_press_start", self.__on_long_press_start.handler) + self._add_event_handler( + "long_press_start", self.__on_long_press_start.get_handler() + ) self.__on_long_press_end = EventHandler( lambda e: LongPressEndEvent(**json.loads(e.data)) ) - self._add_event_handler("long_press_end", self.__on_long_press_end.handler) + self._add_event_handler( + "long_press_end", self.__on_long_press_end.get_handler() + ) self.__on_secondary_long_press_start = EventHandler( lambda e: LongPressStartEvent(**json.loads(e.data)) ) self._add_event_handler( - "secondary_long_press_start", self.__on_secondary_long_press_start.handler + "secondary_long_press_start", + self.__on_secondary_long_press_start.get_handler(), ) self.__on_secondary_long_press_end = EventHandler( lambda e: LongPressEndEvent(**json.loads(e.data)) ) self._add_event_handler( - "secondary_long_press_end", self.__on_secondary_long_press_end.handler + "secondary_long_press_end", + self.__on_secondary_long_press_end.get_handler(), ) self.__on_double_tap_down = EventHandler( lambda e: TapEvent(**json.loads(e.data)) ) - self._add_event_handler("double_tap_down", self.__on_double_tap_down.handler) + self._add_event_handler( + "double_tap_down", self.__on_double_tap_down.get_handler() + ) # on_horizontal_drag @@ -257,19 +268,19 @@ def __init__( lambda e: DragStartEvent(**json.loads(e.data)) ) self._add_event_handler( - "horizontal_drag_start", self.__on_horizontal_drag_start.handler + "horizontal_drag_start", self.__on_horizontal_drag_start.get_handler() ) self.__on_horizontal_drag_update = EventHandler( lambda e: DragUpdateEvent(**json.loads(e.data)) ) self._add_event_handler( - "horizontal_drag_update", self.__on_horizontal_drag_update.handler + "horizontal_drag_update", self.__on_horizontal_drag_update.get_handler() ) self.__on_horizontal_drag_end = EventHandler( lambda e: DragEndEvent(**json.loads(e.data)) ) self._add_event_handler( - "horizontal_drag_end", self.__on_horizontal_drag_end.handler + "horizontal_drag_end", self.__on_horizontal_drag_end.get_handler() ) # on_vertical_drag @@ -278,19 +289,19 @@ def __init__( lambda e: DragStartEvent(**json.loads(e.data)) ) self._add_event_handler( - "vertical_drag_start", self.__on_vertical_drag_start.handler + "vertical_drag_start", self.__on_vertical_drag_start.get_handler() ) self.__on_vertical_drag_update = EventHandler( lambda e: DragUpdateEvent(**json.loads(e.data)) ) self._add_event_handler( - "vertical_drag_update", self.__on_vertical_drag_update.handler + "vertical_drag_update", self.__on_vertical_drag_update.get_handler() ) self.__on_vertical_drag_end = EventHandler( lambda e: DragEndEvent(**json.loads(e.data)) ) self._add_event_handler( - "vertical_drag_end", self.__on_vertical_drag_end.handler + "vertical_drag_end", self.__on_vertical_drag_end.get_handler() ) # on_pan @@ -298,41 +309,41 @@ def __init__( self.__on_pan_start = EventHandler( lambda e: DragStartEvent(**json.loads(e.data)) ) - self._add_event_handler("pan_start", self.__on_pan_start.handler) + self._add_event_handler("pan_start", self.__on_pan_start.get_handler()) self.__on_pan_update = EventHandler( lambda e: DragUpdateEvent(**json.loads(e.data)) ) - self._add_event_handler("pan_update", self.__on_pan_update.handler) + self._add_event_handler("pan_update", self.__on_pan_update.get_handler()) self.__on_pan_end = EventHandler(lambda e: DragEndEvent(**json.loads(e.data))) - self._add_event_handler("pan_end", self.__on_pan_end.handler) + self._add_event_handler("pan_end", self.__on_pan_end.get_handler()) # on_scale self.__on_scale_start = EventHandler( lambda e: ScaleStartEvent(**json.loads(e.data)) ) - self._add_event_handler("scale_start", self.__on_scale_start.handler) + self._add_event_handler("scale_start", self.__on_scale_start.get_handler()) self.__on_scale_update = EventHandler( lambda e: ScaleUpdateEvent(**json.loads(e.data)) ) - self._add_event_handler("scale_update", self.__on_scale_update.handler) + self._add_event_handler("scale_update", self.__on_scale_update.get_handler()) self.__on_scale_end = EventHandler( lambda e: ScaleEndEvent(**json.loads(e.data)) ) - self._add_event_handler("scale_end", self.__on_scale_end.handler) + self._add_event_handler("scale_end", self.__on_scale_end.get_handler()) # on_hover self.__on_hover = EventHandler(lambda e: HoverEvent(**json.loads(e.data))) - self._add_event_handler("hover", self.__on_hover.handler) + self._add_event_handler("hover", self.__on_hover.get_handler()) self.__on_enter = EventHandler(lambda e: HoverEvent(**json.loads(e.data))) - self._add_event_handler("enter", self.__on_enter.handler) + self._add_event_handler("enter", self.__on_enter.get_handler()) self.__on_exit = EventHandler(lambda e: HoverEvent(**json.loads(e.data))) - self._add_event_handler("exit", self.__on_exit.handler) + self._add_event_handler("exit", self.__on_exit.get_handler()) # on_scroll self.__on_scroll = EventHandler(lambda e: ScrollEvent(**json.loads(e.data))) - self._add_event_handler("scroll", self.__on_scroll.handler) + self._add_event_handler("scroll", self.__on_scroll.get_handler()) self.content = content self.mouse_cursor = mouse_cursor diff --git a/sdk/python/flet/grid_view.py b/sdk/python/flet/grid_view.py index f1fc665a5..7142c1125 100644 --- a/sdk/python/flet/grid_view.py +++ b/sdk/python/flet/grid_view.py @@ -150,7 +150,11 @@ def _get_children(self): return self.__controls def clean(self): - Control.clean(self) + super().clean() + self.__controls.clear() + + async def clean_async(self): + await super().clean_async() self.__controls.clear() # horizontal diff --git a/sdk/python/flet/haptic_feedback.py b/sdk/python/flet/haptic_feedback.py index c85e9de9e..df1e343aa 100644 --- a/sdk/python/flet/haptic_feedback.py +++ b/sdk/python/flet/haptic_feedback.py @@ -11,7 +11,7 @@ class HapticFeedback(CallableControl): Allows access to the haptic feedback interface on the device. It is non-visual and should be added to `page.overlay` list. - + Example: ``` import flet as ft @@ -34,6 +34,7 @@ def main(page: ft.Page): Online docs: https://flet.dev/docs/controls/hapticfeedback """ + def __init__( self, ref: Optional[Ref] = None, @@ -55,11 +56,23 @@ def _is_isolated(self): def heavy_impact(self): self._call_method("heavy_impact", [], wait_for_result=False) + async def heavy_impact_async(self): + await self._call_method_async("heavy_impact", [], wait_for_result=False) + def light_impact(self): self._call_method("light_impact", [], wait_for_result=False) + async def light_impact_async(self): + await self._call_method_async("light_impact", [], wait_for_result=False) + def medium_impact(self): self._call_method("medium_impact", [], wait_for_result=False) + async def medium_impact_async(self): + await self._call_method_async("medium_impact", [], wait_for_result=False) + def vibrate(self): self._call_method("vibrate", [], wait_for_result=False) + + async def vibrate_async(self): + await self._call_method_async("vibrate", [], wait_for_result=False) diff --git a/sdk/python/flet/list_view.py b/sdk/python/flet/list_view.py index 17c8d2ff1..fa0644a5e 100644 --- a/sdk/python/flet/list_view.py +++ b/sdk/python/flet/list_view.py @@ -142,7 +142,11 @@ def _get_children(self): return self.__controls def clean(self): - Control.clean(self) + super().clean() + self.__controls.clear() + + async def clean_async(self): + await super().clean_async() self.__controls.clear() # horizontal diff --git a/sdk/python/flet/page.py b/sdk/python/flet/page.py index 14b78c5d9..c59142f1f 100644 --- a/sdk/python/flet/page.py +++ b/sdk/python/flet/page.py @@ -1,16 +1,16 @@ +import asyncio import json import logging import threading import time import uuid from dataclasses import dataclass -from typing import Any, cast +from typing import Any, Tuple, Union, cast from urllib.parse import urlparse from beartype import beartype from beartype.typing import Dict, List, Optional -from flet import constants from flet.app_bar import AppBar from flet.auth.authorization import Authorization from flet.auth.oauth_provider import OAuthProvider @@ -40,6 +40,7 @@ ThemeMode, ThemeModeString, ) +from flet.utils import is_asyncio, is_coroutine from flet.view import View try: @@ -82,9 +83,9 @@ def __init__(self, conn: Connection, session_id): self.__query = QueryString(page=self) # Querystring self._session_id = session_id self._index = {self._Control__uid: self} # index with all page controls - self._last_event = None - self._event_available = threading.Event() - self._fetch_page_details() + + self.__lock = threading.Lock() if not is_asyncio() else None + self.__async_lock = asyncio.Lock() if is_asyncio() else None self.__views = [View()] self.__default_view = self.__views[0] @@ -100,15 +101,18 @@ def __init__(self, conn: Connection, session_id): self.__authorization: Optional[Authorization] = None self.__on_close = EventHandler() - self._add_event_handler("close", self.__on_close.handler) + self._add_event_handler("close", self.__on_close.get_handler()) self.__on_resize = EventHandler() - self._add_event_handler("resize", self.__on_resize.handler) + self._add_event_handler("resize", self.__on_resize.get_handler()) self.__last_route = None # authorize/login/logout self.__on_login = EventHandler() - self._add_event_handler("authorize", self.__on_authorize) + self._add_event_handler( + "authorize", + self.__on_authorize if not is_asyncio() else self.__on_authorize_async, + ) self.__on_logout = EventHandler() # route_change @@ -120,54 +124,45 @@ def convert_route_change_event(e): return RouteChangeEvent(route=e.data) self.__on_route_change = EventHandler(convert_route_change_event) - self._add_event_handler("route_change", self.__on_route_change.handler) + self._add_event_handler("route_change", self.__on_route_change.get_handler()) def convert_view_pop_event(e): return ViewPopEvent(view=cast(View, self.get_control(e.data))) self.__on_view_pop = EventHandler(convert_view_pop_event) - self._add_event_handler("view_pop", self.__on_view_pop.handler) + self._add_event_handler("view_pop", self.__on_view_pop.get_handler()) def convert_keyboard_event(e): d = json.loads(e.data) return KeyboardEvent(**d) self.__on_keyboard_event = EventHandler(convert_keyboard_event) - self._add_event_handler("keyboard_event", self.__on_keyboard_event.handler) + self._add_event_handler( + "keyboard_event", self.__on_keyboard_event.get_handler() + ) - self.__method_calls: Dict[str, threading.Event] = {} + self.__method_calls: Dict[str, Union[threading.Event, asyncio.Event]] = {} self.__method_call_results: Dict[ - threading.Event, tuple[Optional[str], Optional[str]] + Union[threading.Event, asyncio.Event], tuple[Optional[str], Optional[str]] ] = {} - self._add_event_handler("invoke_method_result", self._on_invoke_method_result) + self._add_event_handler("invoke_method_result", self.__on_invoke_method_result) self.__on_window_event = EventHandler() - self._add_event_handler("window_event", self.__on_window_event.handler) + self._add_event_handler("window_event", self.__on_window_event.get_handler()) self.__on_connect = EventHandler() - self._add_event_handler("connect", self.__on_connect.handler) + self._add_event_handler("connect", self.__on_connect.get_handler()) self.__on_disconnect = EventHandler() - self._add_event_handler("disconnect", self.__on_disconnect.handler) + self._add_event_handler("disconnect", self.__on_disconnect.get_handler()) self.__on_error = EventHandler() - self._add_event_handler("error", self.__on_error.handler) - - def __enter__(self): - return self - - def __exit__(self, type, value, traceback): - self.close() + self._add_event_handler("error", self.__on_error.get_handler()) def get_control(self, id): return self._index.get(id) def _before_build_command(self): super()._before_build_command() - # fonts self._set_attr_json("fonts", self.__fonts) - - # light theme self._set_attr_json("theme", self.__theme) - - # dark theme self._set_attr_json("darkTheme", self.__dark_theme) # keyboard event @@ -180,27 +175,43 @@ def _get_children(self): children.append(self.__offstage) return children - def _fetch_page_details(self): + def fetch_page_details(self): assert self.__conn.page_name is not None values = self.__conn.send_commands( self._session_id, - [ - Command(0, "get", ["page", "route"]), - Command( - 0, - "get", - ["page", "pwa"], - ), - Command(0, "get", ["page", "web"]), - Command(0, "get", ["page", "platform"]), - Command(0, "get", ["page", "width"]), - Command(0, "get", ["page", "height"]), - Command(0, "get", ["page", "windowWidth"]), - Command(0, "get", ["page", "windowHeight"]), - Command(0, "get", ["page", "windowTop"]), - Command(0, "get", ["page", "windowLeft"]), - ], + self.__get_page_detail_commands(), + ).results + self.__set_page_details(values) + + async def fetch_page_details_async(self): + assert self.__conn.page_name is not None + values = ( + await self.__conn.send_commands_async( + self._session_id, + self.__get_page_detail_commands(), + ) ).results + self.__set_page_details(values) + + def __get_page_detail_commands(self): + return [ + Command(0, "get", ["page", "route"]), + Command( + 0, + "get", + ["page", "pwa"], + ), + Command(0, "get", ["page", "web"]), + Command(0, "get", ["page", "platform"]), + Command(0, "get", ["page", "width"]), + Command(0, "get", ["page", "height"]), + Command(0, "get", ["page", "windowWidth"]), + Command(0, "get", ["page", "windowHeight"]), + Command(0, "get", ["page", "windowTop"]), + Command(0, "get", ["page", "windowLeft"]), + ] + + def __set_page_details(self, values): self._set_attr("route", values[0], False) self._set_attr("pwa", values[1], False) self._set_attr("web", values[2], False) @@ -213,29 +224,166 @@ def _fetch_page_details(self): self._set_attr("windowLeft", values[9], False) def update(self, *controls): - added_controls = [] - with self._lock: + assert self.__lock, "Sync method calls are not supported in async app." + with self.__lock: if len(controls) == 0: - added_controls = self.__update(self) + r = self.__update(self) else: - added_controls = self.__update(*controls) - for ctrl in added_controls: - ctrl.did_mount() + r = self.__update(*controls) + self.__handle_mount_unmount(*r) + + async def update_async(self, *controls): + assert ( + self.__async_lock + ), "Async method calls are not supported in a regular app." + async with self.__async_lock: + if len(controls) == 0: + r = await self.__update_async(self) + else: + r = await self.__update_async(*controls) + await self.__handle_mount_unmount_async(*r) + + def add(self, *controls): + assert self.__lock, "Sync method calls are not supported in async app." + with self.__lock: + self._controls.extend(controls) + r = self.__update(self) + self.__handle_mount_unmount(*r) + + async def add_async(self, *controls): + assert ( + self.__async_lock + ), "Async method calls are not supported in a regular app." + async with self.__async_lock: + self._controls.extend(controls) + r = await self.__update_async(self) + await self.__handle_mount_unmount_async(*r) + + def insert(self, at, *controls): + assert self.__lock, "Sync method calls are not supported in async app." + with self.__lock: + n = at + for control in controls: + self._controls.insert(n, control) + n += 1 + r = self.__update(self) + self.__handle_mount_unmount(*r) + + async def insert_async(self, at, *controls): + assert ( + self.__async_lock + ), "Async method calls are not supported in a regular app." + async with self.__async_lock: + n = at + for control in controls: + self._controls.insert(n, control) + n += 1 + r = await self.__update_async(self) + await self.__handle_mount_unmount_async(*r) + + def remove(self, *controls): + assert self.__lock, "Sync method calls are not supported in async app." + with self.__lock: + for control in controls: + self._controls.remove(control) + r = self.__update(self) + self.__handle_mount_unmount(*r) + + async def remove_async(self, *controls): + assert ( + self.__async_lock + ), "Async method calls are not supported in a regular app." + async with self.__async_lock: + for control in controls: + self._controls.remove(control) + r = await self.__update_async(self) + await self.__handle_mount_unmount_async(*r) + + def remove_at(self, index): + assert self.__lock, "Sync method calls are not supported in async app." + with self.__lock: + self._controls.pop(index) + r = self.__update(self) + self.__handle_mount_unmount(*r) + + async def remove_at_async(self, index): + assert ( + self.__async_lock + ), "Async method calls are not supported in a regular app." + async with self.__async_lock: + self._controls.pop(index) + r = await self.__update_async(self) + await self.__handle_mount_unmount_async(*r) + + def clean(self): + self._clean(self) + self._controls.clear() + + async def clean_async(self): + await self._clean_async(self) + self._controls.clear() + + def _clean(self, control: Control): + assert self.__lock, "Sync method calls are not supported in async app." + with self.__lock: + control._previous_children.clear() + assert control.uid is not None + removed_controls = [] + for child in control._get_children(): + removed_controls.extend( + self._remove_control_recursively(self.index, child) + ) + self._send_command("clean", [control.uid]) + for c in removed_controls: + c.will_unmount() + + async def _clean_async(self, control: Control): + assert ( + self.__async_lock + ), "Async method calls are not supported in a regular app." + async with self.__async_lock: + control._previous_children.clear() + assert control.uid is not None + removed_controls = [] + for child in control._get_children(): + removed_controls.extend( + self._remove_control_recursively(self.index, child) + ) + await self._send_command_async("clean", [control.uid]) + for c in removed_controls: + await c.will_unmount_async() - def __update(self, *controls) -> List[Control]: + def __update(self, *controls) -> Tuple[List[Control], List[Control]]: + commands, added_controls, removed_controls = self.__prepare_update(*controls) + results = self.__conn.send_commands(self._session_id, commands).results + self.__update_control_ids(added_controls, results) + return added_controls, removed_controls + + async def __update_async(self, *controls) -> Tuple[List[Control], List[Control]]: + commands, added_controls, removed_controls = self.__prepare_update(*controls) + results = ( + await self.__conn.send_commands_async(self._session_id, commands) + ).results + self.__update_control_ids(added_controls, results) + return added_controls, removed_controls + + def __prepare_update(self, *controls): added_controls = [] + removed_controls = [] commands = [] # build commands for control in controls: - control.build_update_commands(self._index, added_controls, commands) + control.build_update_commands( + self._index, commands, added_controls, removed_controls + ) if len(commands) == 0: - return added_controls + return commands, added_controls, removed_controls - # execute commands - results = self.__conn.send_commands(self._session_id, commands).results + return commands, added_controls, removed_controls + def __update_control_ids(self, added_controls, results): if len(results) > 0: n = 0 for line in results: @@ -247,93 +395,73 @@ def __update(self, *controls) -> List[Control]: self._index[id] = added_controls[n] n += 1 - return added_controls - - def add(self, *controls): - added_controls = [] - with self._lock: - self._controls.extend(controls) - added_controls = self.__update(self) - for ctrl in added_controls: - ctrl.did_mount() - - def insert(self, at, *controls): - added_controls = [] - with self._lock: - n = at - for control in controls: - self._controls.insert(n, control) - n += 1 - added_controls = self.__update(self) - for ctrl in added_controls: - ctrl.did_mount() - def remove(self, *controls): - added_controls = [] - with self._lock: - for control in controls: - self._controls.remove(control) - added_controls = self.__update(self) + def __handle_mount_unmount(self, added_controls, removed_controls): + for ctrl in removed_controls: + ctrl.will_unmount() for ctrl in added_controls: ctrl.did_mount() - def remove_at(self, index): - added_controls = [] - with self._lock: - self._controls.pop(index) - added_controls = self.__update(self) + async def __handle_mount_unmount_async(self, added_controls, removed_controls): + for ctrl in removed_controls: + await ctrl.will_unmount_async() for ctrl in added_controls: - ctrl.did_mount() - - def clean(self): - with self._lock: - self._previous_children.clear() - for child in self._get_children(): - self._remove_control_recursively(self._index, child) - self._controls.clear() - assert self.uid is not None - return self._send_command("clean", [self.uid]) + await ctrl.did_mount_async() def error(self, message=""): - with self._lock: + assert self.__lock, "Sync method calls are not supported in async app." + with self.__lock: self._send_command("error", [message]) + async def error_async(self, message=""): + assert ( + self.__async_lock + ), "Async method calls are not supported in a regular app." + async with self.__async_lock: + await self._send_command_async("error", [message]) + def on_event(self, e: Event): logging.info(f"page.on_event: {e.target} {e.name} {e.data}") - - with self._lock: + assert self.__lock, "Sync method calls are not supported in async app." + with self.__lock: if e.target == "page" and e.name == "change": - for props in json.loads(e.data): - id = props["i"] - if id in self._index: - for name in props: - if name != "i": - self._index[id]._set_attr( - name, props[name], dirty=False - ) + self.__on_page_change_event(e.data) elif e.target in self._index: - self._last_event = ControlEvent( - e.target, e.name, e.data, self._index[e.target], self - ) + ce = ControlEvent(e.target, e.name, e.data, self._index[e.target], self) handler = self._index[e.target].event_handlers.get(e.name) if handler: - t = threading.Thread( - target=handler, args=(self._last_event,), daemon=True - ) + t = threading.Thread(target=handler, args=(ce,), daemon=True) t.start() - self._event_available.set() - def wait_event(self) -> ControlEvent: - self._event_available.clear() - self._event_available.wait() - assert self._last_event is not None - return self._last_event + async def on_event_async(self, e: Event): + logging.info(f"page.on_event_async: {e.target} {e.name} {e.data}") + + if e.target == "page" and e.name == "change": + async with self.__async_lock: + self.__on_page_change_event(e.data) + + elif e.target in self._index: + ce = ControlEvent(e.target, e.name, e.data, self._index[e.target], self) + handler = self._index[e.target].event_handlers.get(e.name) + if handler: + if is_coroutine(handler): + await handler(ce) + else: + handler(ce) + + def __on_page_change_event(self, data): + for props in json.loads(data): + id = props["i"] + if id in self._index: + for name in props: + if name != "i": + self._index[id]._set_attr(name, props[name], dirty=False) def go(self, route, **kwargs): self.route = route if kwargs == {} else route + self.query.post(kwargs) - self.__on_route_change.handler( + self.__on_route_change.get_handler()( ControlEvent( target="page", name="route_change", @@ -345,13 +473,35 @@ def go(self, route, **kwargs): self.update() self.query() # Update query url (required when using go) + async def go_async(self, route, **kwargs): + self.route = route if kwargs == {} else route + self.query.post(kwargs) + + await self.__on_route_change.get_handler()( + ControlEvent( + target="page", + name="route_change", + data=self.route, + page=self, + control=self, + ) + ) + await self.update_async() + self.query() + def get_upload_url(self, file_name: str, expires: int): r = self._send_command( "getUploadUrl", attrs={"file": file_name, "expires": str(expires)} ) if r.error: raise Exception(r.error) + return r.result + async def get_upload_url_async(self, file_name: str, expires: int): + r = await self._send_command_async( + "getUploadUrl", attrs={"file": file_name, "expires": str(expires)} + ) + if r.error: + raise Exception(r.error) return r.result def login( @@ -364,16 +514,15 @@ def login( on_open_authorization_url=None, complete_page_html: Optional[str] = None, redirect_to_page=False, - authorization=Authorization + authorization=Authorization, ): self.__authorization = authorization( provider, fetch_user=fetch_user, fetch_groups=fetch_groups, scope=scope, - saved_token=saved_token, ) - if saved_token == None: + if saved_token is None: authorization_url, state = self.__authorization.get_authorization_data() auth_attrs = {"state": state} if complete_page_html: @@ -393,7 +542,52 @@ def login( authorization_url, "flet_oauth_signin", web_popup_window=self.web ) else: - self.__on_login.handler(LoginEvent(error="", error_description="")) + self.__authorization.dehydrate_token(saved_token) + self.__on_login.get_handler()(LoginEvent(error="", error_description="")) + return self.__authorization + + async def login_async( + self, + provider: OAuthProvider, + fetch_user=True, + fetch_groups=False, + scope: Optional[List[str]] = None, + saved_token: Optional[str] = None, + on_open_authorization_url=None, + complete_page_html: Optional[str] = None, + redirect_to_page=False, + authorization=Authorization, + ): + self.__authorization = authorization( + provider, + fetch_user=fetch_user, + fetch_groups=fetch_groups, + scope=scope, + ) + if saved_token is None: + authorization_url, state = self.__authorization.get_authorization_data() + auth_attrs = {"state": state} + if complete_page_html: + auth_attrs["completePageHtml"] = complete_page_html + if redirect_to_page: + up = urlparse(provider.redirect_url) + auth_attrs["completePageUrl"] = up._replace( + path=self.__conn.page_name + ).geturl() + result = await self._send_command_async("oauthAuthorize", attrs=auth_attrs) + if result.error != "": + raise Exception(result.error) + if on_open_authorization_url: + await on_open_authorization_url(authorization_url) + else: + await self.launch_url_async( + authorization_url, "flet_oauth_signin", web_popup_window=self.web + ) + else: + await self.__authorization.dehydrate_token_async(saved_token) + await self.__on_login.get_handler()( + LoginEvent(error="", error_description="") + ) return self.__authorization def __on_authorize(self, e): @@ -421,17 +615,46 @@ def __on_authorize(self, e): self.__authorization.request_token(code) except Exception as ex: login_evt.error = str(ex) - self.__on_login.handler(login_evt) + self.__on_login.get_handler()(login_evt) + + async def __on_authorize_async(self, e): + assert self.__authorization is not None + d = json.loads(e.data) + state = d["state"] + assert state == self.__authorization.state + + if not self.web: + if self.platform in ["ios", "android"]: + # close web view on mobile + await self.close_in_app_web_view_async() + else: + # activate desktop window + await self.window_to_front_async() + + login_evt = LoginEvent( + error=d["error"], error_description=d["error_description"] + ) + if login_evt.error == "": + # perform token request + code = d["code"] + assert code not in [None, ""] + try: + await self.__authorization.request_token_async(code) + except Exception as ex: + login_evt.error = str(ex) + await self.__on_login.get_handler()(login_evt) def logout(self): self.__authorization = None - self.__on_logout.handler( + self.__on_logout.get_handler()( ControlEvent(target="page", name="logout", data="", control=self, page=self) ) - def close(self): - if self._session_id == constants.ZERO_SESSION: - self.__conn.close() + async def logout_async(self): + self.__authorization = None + await self.__on_logout.get_handler()( + ControlEvent(target="page", name="logout", data="", control=self, page=self) + ) def _send_command( self, @@ -449,13 +672,36 @@ def _send_command( ), ) + async def _send_command_async( + self, + name: str, + values: Optional[List[str]] = None, + attrs: Optional[Dict[str, str]] = None, + ): + return await self.__conn.send_command_async( + self._session_id, + Command( + indent=0, + name=name, + values=values if values is not None else [], + attrs=attrs or {}, + ), + ) + @beartype def set_clipboard(self, value: str): self.__offstage.clipboard.set_data(value) + @beartype + async def set_clipboard_async(self, value: str): + await self.__offstage.clipboard.set_data_async(value) + def get_clipboard(self): return self.__offstage.clipboard.get_data() + async def get_clipboard_async(self): + return await self.__offstage.clipboard.get_data_async() + @beartype def launch_url( self, @@ -464,6 +710,45 @@ def launch_url( web_popup_window: bool = False, window_width: Optional[int] = None, window_height: Optional[int] = None, + ): + self.invoke_method( + "launchUrl", + self.__get_launch_url_args( + url=url, + web_window_name=web_window_name, + web_popup_window=web_popup_window, + window_width=window_width, + window_height=window_height, + ), + ) + + @beartype + async def launch_url_async( + self, + url: str, + web_window_name: Optional[str] = None, + web_popup_window: bool = False, + window_width: Optional[int] = None, + window_height: Optional[int] = None, + ): + await self.invoke_method_async( + "launchUrl", + self.__get_launch_url_args( + url=url, + web_window_name=web_window_name, + web_popup_window=web_popup_window, + window_width=window_width, + window_height=window_height, + ), + ) + + def __get_launch_url_args( + self, + url: str, + web_window_name: Optional[str] = None, + web_popup_window: bool = False, + window_width: Optional[int] = None, + window_height: Optional[int] = None, ): args = {"url": url} if web_window_name != None: @@ -474,20 +759,35 @@ def launch_url( args["window_width"] = str(window_width) if window_height != None: args["window_height"] = str(window_height) - self.invoke_method("launchUrl", args) + return args @beartype def can_launch_url(self, url: str): args = {"url": url} return self.invoke_method("canLaunchUrl", args, wait_for_result=True) == "true" + @beartype + async def can_launch_url_async(self, url: str): + args = {"url": url} + return ( + await self.invoke_method_async("canLaunchUrl", args, wait_for_result=True) + == "true" + ) + def close_in_app_web_view(self): self.invoke_method("closeInAppWebView") + async def close_in_app_web_view_async(self): + await self.invoke_method_async("closeInAppWebView") + @beartype def window_to_front(self): self.invoke_method("windowToFront") + @beartype + async def window_to_front_async(self): + await self.invoke_method_async("windowToFront") + def invoke_method( self, method_name: str, @@ -530,7 +830,51 @@ def invoke_method( return None return result - def _on_invoke_method_result(self, e): + async def invoke_method_async( + self, + method_name: str, + arguments: Optional[Dict[str, str]] = None, + wait_for_result: bool = False, + ) -> Optional[str]: + method_id = uuid.uuid4().hex + + # register callback + evt: Optional[asyncio.Event] = None + if wait_for_result: + evt = asyncio.Event() + self.__method_calls[method_id] = evt + + # call method + result = await self._send_command_async( + "invokeMethod", values=[method_id, method_name], attrs=arguments + ) + + if result.error != "": + if wait_for_result: + del self.__method_calls[method_id] + raise Exception(result.error) + + if not wait_for_result: + return + + assert evt is not None + + try: + await asyncio.wait_for(evt.wait(), timeout=5) + except TimeoutError: + del self.__method_calls[method_id] + raise Exception( + f"Timeout waiting for invokeMethod {method_name}({arguments}) call" + ) + + result, err = self.__method_call_results.pop(evt) + if err != None: + raise Exception(err) + if result == None: + return None + return result + + def __on_invoke_method_result(self, e): d = json.loads(e.data) result = InvokeMethodResults(**d) evt = self.__method_calls.pop(result.method_id, None) @@ -544,18 +888,35 @@ def show_snack_bar(self, snack_bar: SnackBar): self.__offstage.snack_bar = snack_bar self.__offstage.update() + @beartype + async def show_snack_bar_async(self, snack_bar: SnackBar): + self.__offstage.snack_bar = snack_bar + await self.__offstage.update_async() + def window_destroy(self): self._set_attr("windowDestroy", "true") self.update() + async def window_destroy_async(self): + self._set_attr("windowDestroy", "true") + await self.update_async() + def window_center(self): self._set_attr("windowCenter", str(time.time())) self.update() + async def window_center_async(self): + self._set_attr("windowCenter", str(time.time())) + await self.update_async() + def window_close(self): self._set_attr("windowClose", str(time.time())) self.update() + async def window_close_async(self): + self._set_attr("windowClose", str(time.time())) + await self.update_async() + # QueryString @property def query(self): diff --git a/sdk/python/flet/pubsub.py b/sdk/python/flet/pubsub.py index 632016269..8b9bce161 100644 --- a/sdk/python/flet/pubsub.py +++ b/sdk/python/flet/pubsub.py @@ -1,11 +1,15 @@ +import asyncio import logging import threading -from typing import Any, Callable, Dict, Iterable +from typing import Any, Callable, Coroutine, Dict, Iterable + +from flet.utils import is_asyncio class PubSubHub: def __init__(self): self.__lock = threading.Lock() + self.__async_lock = asyncio.Lock() self.__subscribers: Dict[str, Callable] = {} # key: session_id, value: handler self.__topic_subscribers: Dict[ str, Dict[str, Callable] @@ -20,6 +24,12 @@ def send_all(self, message: Any): for handler in self.__subscribers.values(): self.__send(handler, [message]) + async def send_all_async(self, message: Any): + logging.debug(f"pubsub.send_all_async({message})") + async with self.__async_lock: + for handler in self.__subscribers.values(): + await self.__send_async(handler, [message]) + def send_all_on_topic(self, topic: str, message: Any): logging.debug(f"pubsub.send_all_on_topic({topic}, {message})") with self.__lock: @@ -27,6 +37,13 @@ def send_all_on_topic(self, topic: str, message: Any): for handler in self.__topic_subscribers[topic].values(): self.__send(handler, [topic, message]) + async def send_all_on_topic_async(self, topic: str, message: Any): + logging.debug(f"pubsub.send_all_on_topic_async({topic}, {message})") + async with self.__async_lock: + if topic in self.__topic_subscribers: + for handler in self.__topic_subscribers[topic].values(): + await self.__send_async(handler, [topic, message]) + def send_others(self, except_session_id: str, message: Any): logging.debug(f"pubsub.send_others({except_session_id}, {message})") with self.__lock: @@ -34,6 +51,13 @@ def send_others(self, except_session_id: str, message: Any): if except_session_id != session_id: self.__send(handler, [message]) + async def send_others_async(self, except_session_id: str, message: Any): + logging.debug(f"pubsub.send_others_async({except_session_id}, {message})") + async with self.__async_lock: + for session_id, handler in self.__subscribers.items(): + if except_session_id != session_id: + await self.__send_async(handler, [message]) + def send_others_on_topic(self, except_session_id: str, topic: str, message: Any): logging.debug( f"pubsub.send_others_on_topic({except_session_id}, {topic}, {message})" @@ -44,35 +68,70 @@ def send_others_on_topic(self, except_session_id: str, topic: str, message: Any) if except_session_id != session_id: self.__send(handler, [topic, message]) + async def send_others_on_topic_async( + self, except_session_id: str, topic: str, message: Any + ): + logging.debug( + f"pubsub.send_others_on_topic_async({except_session_id}, {topic}, {message})" + ) + async with self.__async_lock: + if topic in self.__topic_subscribers: + for session_id, handler in self.__topic_subscribers[topic].items(): + if except_session_id != session_id: + await self.__send_async(handler, [topic, message]) + def subscribe(self, session_id: str, handler: Callable): logging.debug(f"pubsub.subscribe({session_id})") with self.__lock: self.__subscribers[session_id] = handler + async def subscribe_async(self, session_id: str, handler): + logging.debug(f"pubsub.subscribe_async({session_id})") + async with self.__async_lock: + self.__subscribers[session_id] = handler + def subscribe_topic(self, session_id: str, topic: str, handler: Callable): logging.debug(f"pubsub.subscribe_topic({session_id}, {topic})") with self.__lock: - topic_subscribers = self.__topic_subscribers.get(topic) - if topic_subscribers is None: - topic_subscribers = {} - self.__topic_subscribers[topic] = topic_subscribers - topic_subscribers[session_id] = handler - subscriber_topics = self.__subscriber_topics.get(session_id) - if subscriber_topics is None: - subscriber_topics = {} - self.__subscriber_topics[session_id] = subscriber_topics - subscriber_topics[topic] = handler + self.__subscribe_topic(session_id, topic, handler) + + async def subscribe_topic_async(self, session_id: str, topic: str, handler): + logging.debug(f"pubsub.subscribe_topic_async({session_id}, {topic})") + async with self.__async_lock: + self.__subscribe_topic(session_id, topic, handler) + + def __subscribe_topic(self, session_id: str, topic: str, handler): + topic_subscribers = self.__topic_subscribers.get(topic) + if topic_subscribers is None: + topic_subscribers = {} + self.__topic_subscribers[topic] = topic_subscribers + topic_subscribers[session_id] = handler + subscriber_topics = self.__subscriber_topics.get(session_id) + if subscriber_topics is None: + subscriber_topics = {} + self.__subscriber_topics[session_id] = subscriber_topics + subscriber_topics[topic] = handler def unsubscribe(self, session_id: str): logging.debug(f"pubsub.unsubscribe({session_id})") with self.__lock: self.__unsubscribe(session_id) + async def unsubscribe_async(self, session_id: str): + logging.debug(f"pubsub.unsubscribe_async({session_id})") + async with self.__async_lock: + self.__unsubscribe(session_id) + def unsubscribe_topic(self, session_id: str, topic: str): logging.debug(f"pubsub.unsubscribe({session_id}, {topic})") with self.__lock: self.__unsubscribe_topic(session_id, topic) + async def unsubscribe_topic_async(self, session_id: str, topic: str): + logging.debug(f"pubsub.unsubscribe_topic_async({session_id}, {topic})") + async with self.__async_lock: + self.__unsubscribe_topic(session_id, topic) + def unsubscribe_all(self, session_id: str): logging.debug(f"pubsub.unsubscribe_all({session_id})") with self.__lock: @@ -81,6 +140,14 @@ def unsubscribe_all(self, session_id: str): for topic in self.__subscriber_topics[session_id].keys(): self.__unsubscribe_topic(session_id, topic) + async def unsubscribe_all_async(self, session_id: str): + logging.debug(f"pubsub.unsubscribe_all_async({session_id})") + async with self.__async_lock: + self.__unsubscribe(session_id) + if session_id in self.__subscriber_topics: + for topic in self.__subscriber_topics[session_id].keys(): + self.__unsubscribe_topic(session_id, topic) + def __unsubscribe(self, session_id: str): logging.debug(f"pubsub.__unsubscribe({session_id})") self.__subscribers.pop(session_id) @@ -106,6 +173,9 @@ def __send(self, handler: Callable, args: Iterable): ) th.start() + async def __send_async(self, handler, args): + asyncio.create_task(handler(**args)) + class PubSub: def __init__(self, pubsub: PubSubHub, session_id: str): @@ -115,26 +185,55 @@ def __init__(self, pubsub: PubSubHub, session_id: str): def send_all(self, message: Any): self.__pubsub.send_all(message) + async def send_all_async(self, message: Any): + await self.__pubsub.send_all_async(message) + def send_all_on_topic(self, topic: str, message: Any): self.__pubsub.send_all_on_topic(topic, message) + async def send_all_on_topic_async(self, topic: str, message: Any): + await self.__pubsub.send_all_on_topic_async(topic, message) + def send_others(self, message: Any): self.__pubsub.send_others(self.__session_id, message) + async def send_others_async(self, message: Any): + await self.__pubsub.send_others_async(self.__session_id, message) + def send_others_on_topic(self, topic: str, message: Any): self.__pubsub.send_others_on_topic(self.__session_id, topic, message) + async def send_others_on_topic_async(self, topic: str, message: Any): + await self.__pubsub.send_others_on_topic_async( + self.__session_id, topic, message + ) + def subscribe(self, handler: Callable): self.__pubsub.subscribe(self.__session_id, handler) + async def subscribe_async(self, handler: Callable): + await self.__pubsub.subscribe_async(self.__session_id, handler) + def subscribe_topic(self, topic: str, handler: Callable): self.__pubsub.subscribe_topic(self.__session_id, topic, handler) + async def subscribe_topic_async(self, topic: str, handler: Callable): + await self.__pubsub.subscribe_topic_async(self.__session_id, topic, handler) + def unsubscribe(self): self.__pubsub.unsubscribe(self.__session_id) + async def unsubscribe_async(self): + await self.__pubsub.unsubscribe_async(self.__session_id) + def unsubscribe_topic(self, topic: str): self.__pubsub.unsubscribe_topic(self.__session_id, topic) + async def unsubscribe_topic_async(self, topic: str): + await self.__pubsub.unsubscribe_topic_async(self.__session_id, topic) + def unsubscribe_all(self): self.__pubsub.unsubscribe_all(self.__session_id) + + async def unsubscribe_all_async(self): + await self.__pubsub.unsubscribe_all_async(self.__session_id) diff --git a/sdk/python/flet/reconnecting_websocket.py b/sdk/python/flet/reconnecting_websocket.py index e60b92925..5d865b015 100644 --- a/sdk/python/flet/reconnecting_websocket.py +++ b/sdk/python/flet/reconnecting_websocket.py @@ -11,11 +11,13 @@ class ReconnectingWebSocket: - def __init__(self, url) -> None: + def __init__( + self, url, on_connect=None, on_failed_connect=None, on_message=None + ) -> None: self._url = url - self._on_connect_handler = None - self._on_failed_connect_handler = None - self._on_message_handler = None + self._on_connect_handler = on_connect + self._on_failed_connect_handler = on_failed_connect + self._on_message_handler = on_message self.connected = threading.Event() self.exit = threading.Event() self.retry = 0 @@ -24,30 +26,6 @@ def __init__(self, url) -> None: ws_logger = logging.getLogger("websocket") ws_logger.setLevel(logging.FATAL) - @property - def on_connect(self, handler): - return self._on_connect_handler - - @on_connect.setter - def on_connect(self, handler): - self._on_connect_handler = handler - - @property - def on_failed_connect(self, handler): - return self._on_failed_connect_handler - - @on_failed_connect.setter - def on_failed_connect(self, handler): - self._on_failed_connect_handler = handler - - @property - def on_message(self, handler): - return self._on_message_handler - - @on_message.setter - def on_message(self, handler): - self._on_message_handler = handler - def _on_open(self, wsapp) -> None: logging.info(f"Successfully connected to {self._url}") websocket.setdefaulttimeout(self.default_timeout) diff --git a/sdk/python/flet/responsive_row.py b/sdk/python/flet/responsive_row.py index fc21c2432..ef01f3024 100644 --- a/sdk/python/flet/responsive_row.py +++ b/sdk/python/flet/responsive_row.py @@ -135,7 +135,11 @@ def _get_children(self): return self.__controls def clean(self): - Control.clean(self) + super().clean() + self.__controls.clear() + + async def clean_async(self): + await super().clean_async() self.__controls.clear() # horizontal_alignment diff --git a/sdk/python/flet/row.py b/sdk/python/flet/row.py index 00efa7600..aad1217ac 100644 --- a/sdk/python/flet/row.py +++ b/sdk/python/flet/row.py @@ -144,7 +144,11 @@ def _get_children(self): return self.__controls def clean(self): - Control.clean(self) + super().clean() + self.__controls.clear() + + async def clean_async(self): + await super().clean_async() self.__controls.clear() # tight diff --git a/sdk/python/flet/sync_connection.py b/sdk/python/flet/sync_connection.py new file mode 100644 index 000000000..76dcfbd1b --- /dev/null +++ b/sdk/python/flet/sync_connection.py @@ -0,0 +1,139 @@ +import json +import logging +import threading +from time import sleep +from typing import List, Optional +import uuid +from flet import constants +from flet.connection import Connection + +from flet.protocol import * +from flet.reconnecting_websocket import ReconnectingWebSocket + + +class SyncConnection(Connection): + def __init__( + self, + server_address: str, + page_name: str, + token: Optional[str], + on_event=None, + on_session_created=None, + ): + super().__init__() + self.page_name = page_name + self.__host_client_id: Optional[str] = None + self.__token = token + self.__server_address = server_address + self.__ws = ReconnectingWebSocket( + self._get_ws_url(server_address), + on_connect=self.__on_ws_connect, + on_message=self.__on_ws_message, + ) + self.__ws_callbacks = {} + self.__on_event = on_event + self.__on_session_created = on_session_created + + def connect(self): + self.__connected = threading.Event() + self.__ws.connect() + for n in range(0, constants.CONNECT_TIMEOUT_SECONDS): + if not self.__connected.is_set(): + sleep(1) + if not self.__connected.is_set(): + self.__ws.close() + raise Exception( + f"Could not connected to Flet server in {constants.CONNECT_TIMEOUT_SECONDS} seconds." + ) + + def __on_ws_connect(self): + payload = RegisterHostClientRequestPayload( + hostClientID=self.__host_client_id, + pageName=self.page_name, + isApp=True, + update=False, + authToken=self.__token, + permissions=None, + ) + response = self._send_message_with_result(Actions.REGISTER_HOST_CLIENT, payload) + register_result = RegisterHostClientResponsePayload(**response) + self.__host_client_id = register_result.hostClientID + self.page_name = register_result.pageName + self.page_url = self.__server_address.rstrip("/") + if self.page_name != constants.INDEX_PAGE: + self.page_url += f"/{self.page_name}" + self.__connected.set() + + def __on_ws_message(self, data): + logging.debug(f"_on_message: {data}") + msg_dict = json.loads(data) + msg = Message(**msg_dict) + if msg.id: + # callback + evt = self.__ws_callbacks[msg.id][0] + self.__ws_callbacks[msg.id] = (None, msg.payload) + evt.set() + elif msg.action == Actions.PAGE_EVENT_TO_HOST: + if self.__on_event is not None: + th = threading.Thread( + target=self.__on_event, + args=( + self, + PageEventPayload(**msg.payload), + ), + daemon=True, + ) + th.start() + # self._on_event(self, PageEventPayload(**msg.payload)) + elif msg.action == Actions.SESSION_CREATED: + if self.__on_session_created is not None: + th = threading.Thread( + target=self.__on_session_created, + args=( + self, + PageSessionCreatedPayload(**msg.payload), + ), + daemon=True, + ) + th.start() + else: + # it's something else + print(msg.payload) + + def send_command(self, session_id: str, command: Command): + assert self.page_name is not None + payload = PageCommandRequestPayload(self.page_name, session_id, command) + response = self._send_message_with_result( + Actions.PAGE_COMMAND_FROM_HOST, payload + ) + result = PageCommandResponsePayload(**response) + if result.error: + raise Exception(result.error) + return result + + def send_commands(self, session_id: str, commands: List[Command]): + assert self.page_name is not None + payload = PageCommandsBatchRequestPayload(self.page_name, session_id, commands) + response = self._send_message_with_result( + Actions.PAGE_COMMANDS_BATCH_FROM_HOST, payload + ) + result = PageCommandsBatchResponsePayload(**response) + if result.error: + raise Exception(result.error) + return result + + def _send_message_with_result(self, action_name, payload): + msg_id = uuid.uuid4().hex + msg = Message(msg_id, action_name, payload) + j = json.dumps(msg, cls=CommandEncoder, separators=(",", ":")) + logging.debug(f"_send_message_with_result: {j}") + evt = threading.Event() + self.__ws_callbacks[msg_id] = (evt, None) + self.__ws.send(j) + evt.wait() + return self.__ws_callbacks.pop(msg_id)[1] + + def close(self): + logging.debug("Closing connection...") + if self.__ws is not None: + self.__ws.close() diff --git a/sdk/python/flet/textfield.py b/sdk/python/flet/textfield.py index 01bf2a9a4..02167b429 100644 --- a/sdk/python/flet/textfield.py +++ b/sdk/python/flet/textfield.py @@ -273,6 +273,10 @@ def focus(self): self._set_attr_json("focus", FocusData()) self.update() + async def focus_async(self): + self._set_attr_json("focus", FocusData()) + await self.update_async() + # value @property def value(self) -> Optional[str]: diff --git a/sdk/python/flet/utils.py b/sdk/python/flet/utils.py index 0138f6de9..80efe768b 100644 --- a/sdk/python/flet/utils.py +++ b/sdk/python/flet/utils.py @@ -1,7 +1,11 @@ +import asyncio +import inspect import math import os import platform +import random import socket +import string import sys import unicodedata import webbrowser @@ -59,6 +63,21 @@ def open_in_browser(url): webbrowser.open(url) +def random_string(length): + return "".join(random.choice(string.ascii_letters) for i in range(length)) + + +def is_asyncio(): + try: + return asyncio.current_task() is not None + except RuntimeError: + return False + + +def is_coroutine(method): + return inspect.iscoroutinefunction(method) + + # https://stackoverflow.com/questions/377017/test-if-executable-exists-in-python def which(program, exclude_exe=None): import os diff --git a/sdk/python/pdm.lock b/sdk/python/pdm.lock index 3dbb911c6..840ee9c27 100644 --- a/sdk/python/pdm.lock +++ b/sdk/python/pdm.lock @@ -1,3 +1,14 @@ +[[package]] +name = "anyio" +version = "3.6.2" +requires_python = ">=3.6.2" +summary = "High level compatibility layer for multiple asynchronous event loop implementations" +dependencies = [ + "idna>=2.8", + "sniffio>=1.1", + "typing-extensions; python_version < \"3.8\"", +] + [[package]] name = "attrs" version = "22.1.0" @@ -16,24 +27,35 @@ version = "2022.12.7" requires_python = ">=3.6" summary = "Python package for providing Mozilla's CA Bundle." +[[package]] +name = "cffi" +version = "1.15.1" +summary = "Foreign Function Interface for Python calling C code." +dependencies = [ + "pycparser", +] + [[package]] name = "cfgv" version = "3.3.1" requires_python = ">=3.6.1" summary = "Validate configuration and produce human readable error messages." -[[package]] -name = "charset-normalizer" -version = "2.1.1" -requires_python = ">=3.6.0" -summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." - [[package]] name = "colorama" version = "0.4.6" requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" summary = "Cross-platform colored terminal text." +[[package]] +name = "cryptography" +version = "39.0.0" +requires_python = ">=3.6" +summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +dependencies = [ + "cffi>=1.12", +] + [[package]] name = "distlib" version = "0.3.6" @@ -51,6 +73,39 @@ version = "3.8.2" requires_python = ">=3.7" summary = "A platform independent file lock." +[[package]] +name = "h11" +version = "0.14.0" +requires_python = ">=3.7" +summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +dependencies = [ + "typing-extensions; python_version < \"3.8\"", +] + +[[package]] +name = "httpcore" +version = "0.16.2" +requires_python = ">=3.7" +summary = "A minimal low-level HTTP client." +dependencies = [ + "anyio<5.0,>=3.0", + "certifi", + "h11<0.15,>=0.13", + "sniffio==1.*", +] + +[[package]] +name = "httpx" +version = "0.23.1" +requires_python = ">=3.7" +summary = "The next generation HTTP client." +dependencies = [ + "certifi", + "httpcore<0.17.0,>=0.15.0", + "rfc3986[idna2008]<2,>=1.3", + "sniffio", +] + [[package]] name = "identify" version = "2.5.10" @@ -129,6 +184,12 @@ dependencies = [ "virtualenv>=20.0.8", ] +[[package]] +name = "pycparser" +version = "2.21" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +summary = "C parser in Python" + [[package]] name = "pytest" version = "7.2.0" @@ -160,15 +221,18 @@ dependencies = [ ] [[package]] -name = "requests" -version = "2.28.1" -requires_python = ">=3.7, <4" -summary = "Python HTTP for Humans." +name = "rfc3986" +version = "1.5.0" +summary = "Validating URI References per RFC 3986" + +[[package]] +name = "rfc3986" +version = "1.5.0" +extras = ["idna2008"] +summary = "Validating URI References per RFC 3986" dependencies = [ - "certifi>=2017.4.17", - "charset-normalizer<3,>=2", - "idna<4,>=2.5", - "urllib3<1.27,>=1.21.1", + "idna", + "rfc3986==1.5.0", ] [[package]] @@ -183,6 +247,12 @@ version = "1.16.0" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" summary = "Python 2 and 3 compatibility utilities" +[[package]] +name = "sniffio" +version = "1.3.0" +requires_python = ">=3.7" +summary = "Sniff out which async library your code is running under" + [[package]] name = "toml" version = "0.10.2" @@ -201,12 +271,6 @@ version = "4.4.0" requires_python = ">=3.7" summary = "Backported and Experimental Type Hints for Python 3.7+" -[[package]] -name = "urllib3" -version = "1.26.13" -requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" -summary = "HTTP library with thread-safe connection pooling, file post, and more." - [[package]] name = "virtualenv" version = "20.17.1" @@ -231,6 +295,12 @@ version = "1.4.2" requires_python = ">=3.7" summary = "WebSocket client for Python with low level API options" +[[package]] +name = "websockets" +version = "10.4" +requires_python = ">=3.7" +summary = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" + [[package]] name = "zipp" version = "3.11.0" @@ -239,9 +309,13 @@ summary = "Backport of pathlib-compatible object wrapper for zip files" [metadata] lock_version = "4.0" -content_hash = "sha256:d0f252d1f7668578b6920f4df7c430b3b482f4aeadb3f686703debc8f88ba27e" +content_hash = "sha256:00c87240a392fbe1f9fa51eef0084924b4e4cb4a7860d2df87620a0cb8084136" [metadata.files] +"anyio 3.6.2" = [ + {url = "https://files.pythonhosted.org/packages/77/2b/b4c0b7a3f3d61adb1a1e0b78f90a94e2b6162a043880704b7437ef297cad/anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, + {url = "https://files.pythonhosted.org/packages/8b/94/6928d4345f2bc1beecbff03325cad43d320717f51ab74ab5a571324f4f5a/anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, +] "attrs 22.1.0" = [ {url = "https://files.pythonhosted.org/packages/1a/cb/c4ffeb41e7137b23755a45e1bfec9cbb76ecf51874c6f1d113984ecaa32c/attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, {url = "https://files.pythonhosted.org/packages/f2/bc/d817287d1aa01878af07c19505fafd1165cd6a119e9d0821ca1d1c20312d/attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, @@ -254,18 +328,105 @@ content_hash = "sha256:d0f252d1f7668578b6920f4df7c430b3b482f4aeadb3f686703debc8f {url = "https://files.pythonhosted.org/packages/37/f7/2b1b0ec44fdc30a3d31dfebe52226be9ddc40cd6c0f34ffc8923ba423b69/certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, {url = "https://files.pythonhosted.org/packages/71/4c/3db2b8021bd6f2f0ceb0e088d6b2d49147671f25832fb17970e9b583d742/certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, ] +"cffi 1.15.1" = [ + {url = "https://files.pythonhosted.org/packages/00/05/23a265a3db411b0bfb721bf7a116c7cecaf3eb37ebd48a6ea4dfb0a3244d/cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {url = "https://files.pythonhosted.org/packages/03/7b/259d6e01a6083acef9d3c8c88990c97d313632bb28fa84d6ab2bb201140a/cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {url = "https://files.pythonhosted.org/packages/0e/65/0d7b5dad821ced4dcd43f96a362905a68ce71e6b5f5cfd2fada867840582/cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {url = "https://files.pythonhosted.org/packages/0e/e2/a23af3d81838c577571da4ff01b799b0c2bbde24bd924d97e228febae810/cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {url = "https://files.pythonhosted.org/packages/10/72/617ee266192223a38b67149c830bd9376b69cf3551e1477abc72ff23ef8e/cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {url = "https://files.pythonhosted.org/packages/18/8f/5ff70c7458d61fa8a9752e5ee9c9984c601b0060aae0c619316a1e1f1ee5/cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {url = "https://files.pythonhosted.org/packages/1d/76/bcebbbab689f5f6fc8a91e361038a3001ee2e48c5f9dbad0a3b64a64cc9e/cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {url = "https://files.pythonhosted.org/packages/22/c6/df826563f55f7e9dd9a1d3617866282afa969fe0d57decffa1911f416ed8/cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {url = "https://files.pythonhosted.org/packages/23/8b/2e8c2469eaf89f7273ac685164949a7e644cdfe5daf1c036564208c3d26b/cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {url = "https://files.pythonhosted.org/packages/2b/a8/050ab4f0c3d4c1b8aaa805f70e26e84d0e27004907c5b8ecc1d31815f92a/cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, + {url = "https://files.pythonhosted.org/packages/2d/86/3ca57cddfa0419f6a95d1c8478f8f622ba597e3581fd501bbb915b20eb75/cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {url = "https://files.pythonhosted.org/packages/2e/7a/68c35c151e5b7a12650ecc12fdfb85211aa1da43e9924598451c4a0a3839/cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {url = "https://files.pythonhosted.org/packages/32/2a/63cb8c07d151de92ff9d897b2eb27ba6a0e78dda8e4c5f70d7b8c16cd6a2/cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {url = "https://files.pythonhosted.org/packages/32/bd/d0809593f7976828f06a492716fbcbbfb62798bbf60ea1f65200b8d49901/cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {url = "https://files.pythonhosted.org/packages/37/5a/c37631a86be838bdd84cc0259130942bf7e6e32f70f4cab95f479847fb91/cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {url = "https://files.pythonhosted.org/packages/3a/12/d6066828014b9ccb2bbb8e1d9dc28872d20669b65aeb4a86806a0757813f/cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {url = "https://files.pythonhosted.org/packages/3a/75/a162315adeaf47e94a3b7f886a8e31d77b9e525a387eef2d6f0efc96a7c8/cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {url = "https://files.pythonhosted.org/packages/3f/fa/dfc242febbff049509e5a35a065bdc10f90d8c8585361c2c66b9c2f97a01/cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {url = "https://files.pythonhosted.org/packages/43/a0/cc7370ef72b6ee586369bacd3961089ab3d94ae712febf07a244f1448ffd/cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {url = "https://files.pythonhosted.org/packages/47/51/3049834f07cd89aceef27f9c56f5394ca6725ae6a15cff5fbdb2f06a24ad/cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {url = "https://files.pythonhosted.org/packages/47/97/137f0e3d2304df2060abb872a5830af809d7559a5a4b6a295afb02728e65/cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {url = "https://files.pythonhosted.org/packages/50/34/4cc590ad600869502c9838b4824982c122179089ed6791a8b1c95f0ff55e/cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {url = "https://files.pythonhosted.org/packages/5b/1a/e1ee5bed11d8b6540c05a8e3c32448832d775364d4461dd6497374533401/cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {url = "https://files.pythonhosted.org/packages/5d/4e/4e0bb5579b01fdbfd4388bd1eb9394a989e1336203a4b7f700d887b233c1/cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {url = "https://files.pythonhosted.org/packages/5d/6f/3a2e167113eabd46ed300ff3a6a1e9277a3ad8b020c4c682f83e9326fcf7/cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {url = "https://files.pythonhosted.org/packages/69/bf/335f8d95510b1a26d7c5220164dc739293a71d5540ecd54a2f66bac3ecb8/cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {url = "https://files.pythonhosted.org/packages/71/d7/0fe0d91b0bbf610fb7254bb164fa8931596e660d62e90fb6289b7ee27b09/cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {url = "https://files.pythonhosted.org/packages/77/b7/d3618d612be01e184033eab90006f8ca5b5edafd17bf247439ea4e167d8a/cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {url = "https://files.pythonhosted.org/packages/79/4b/33494eb0adbcd884656c48f6db0c98ad8a5c678fb8fb5ed41ab546b04d8c/cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {url = "https://files.pythonhosted.org/packages/7c/3e/5d823e5bbe00285e479034bcad44177b7353ec9fdcd7795baac5ccf82950/cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {url = "https://files.pythonhosted.org/packages/85/1f/a3c533f8d377da5ca7edb4f580cc3edc1edbebc45fac8bb3ae60f1176629/cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {url = "https://files.pythonhosted.org/packages/87/4b/64e8bd9d15d6b22b6cb11997094fbe61edf453ea0a97c8675cb7d1c3f06f/cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {url = "https://files.pythonhosted.org/packages/87/ee/ddc23981fc0f5e7b5356e98884226bcb899f95ebaefc3e8e8b8742dd7e22/cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {url = "https://files.pythonhosted.org/packages/88/89/c34caf63029fb7628ec2ebd5c88ae0c9bd17db98c812e4065a4d020ca41f/cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {url = "https://files.pythonhosted.org/packages/91/bc/b7723c2fe7a22eee71d7edf2102cd43423d5f95ff3932ebaa2f82c7ec8d0/cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {url = "https://files.pythonhosted.org/packages/93/d0/2e2b27ea2f69b0ec9e481647822f8f77f5fc23faca2dd00d1ff009940eb7/cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {url = "https://files.pythonhosted.org/packages/9f/52/1e2b43cfdd7d9a39f48bc89fcaee8d8685b1295e205a4f1044909ac14d89/cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {url = "https://files.pythonhosted.org/packages/a4/42/54bdf22cf6c8f95113af645d0bd7be7f9358ea5c2d57d634bb11c6b4d0b2/cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {url = "https://files.pythonhosted.org/packages/a8/16/06b84a7063a4c0a2b081030fdd976022086da9c14e80a9ed4ba0183a98a9/cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {url = "https://files.pythonhosted.org/packages/a9/ba/e082df21ebaa9cb29f2c4e1d7e49a29b90fcd667d43632c6674a16d65382/cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {url = "https://files.pythonhosted.org/packages/aa/02/ab15b3aa572759df752491d5fa0f74128cd14e002e8e3257c1ab1587810b/cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {url = "https://files.pythonhosted.org/packages/ad/26/7b3a73ab7d82a64664c7c4ea470e4ec4a3c73bb4f02575c543a41e272de5/cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {url = "https://files.pythonhosted.org/packages/af/cb/53b7bba75a18372d57113ba934b27d0734206c283c1dfcc172347fbd9f76/cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {url = "https://files.pythonhosted.org/packages/af/da/9441d56d7dd19d07dcc40a2a5031a1f51c82a27cee3705edf53dadcac398/cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {url = "https://files.pythonhosted.org/packages/b3/b8/89509b6357ded0cbacc4e430b21a4ea2c82c2cdeb4391c148b7c7b213bed/cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {url = "https://files.pythonhosted.org/packages/b5/7d/df6c088ef30e78a78b0c9cca6b904d5abb698afb5bc8f5191d529d83d667/cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {url = "https://files.pythonhosted.org/packages/b5/80/ce5ba093c2475a73df530f643a61e2969a53366e372b24a32f08cd10172b/cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {url = "https://files.pythonhosted.org/packages/b7/8b/06f30caa03b5b3ac006de4f93478dbd0239e2a16566d81a106c322dc4f79/cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {url = "https://files.pythonhosted.org/packages/b9/4a/dde4d093a3084d0b0eadfb2703f71e31a5ced101a42c839ac5bbbd1710f2/cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {url = "https://files.pythonhosted.org/packages/c1/25/16a082701378170559bb1d0e9ef2d293cece8dc62913d79351beb34c5ddf/cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {url = "https://files.pythonhosted.org/packages/c2/0b/3b09a755ddb977c167e6d209a7536f6ade43bb0654bad42e08df1406b8e4/cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {url = "https://files.pythonhosted.org/packages/c5/ff/3f9d73d480567a609e98beb0c64359f8e4f31cb6a407685da73e5347b067/cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {url = "https://files.pythonhosted.org/packages/c6/3d/dd085bb831b22ce4d0b7ba8550e6d78960f02f770bbd1314fea3580727f8/cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {url = "https://files.pythonhosted.org/packages/c9/e3/0a52838832408cfbbf3a59cb19bcd17e64eb33795c9710ca7d29ae10b5b7/cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {url = "https://files.pythonhosted.org/packages/d3/56/3e94aa719ae96eeda8b68b3ec6e347e0a23168c6841dc276ccdcdadc9f32/cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {url = "https://files.pythonhosted.org/packages/d3/e1/e55ca2e0dd446caa2cc8f73c2b98879c04a1f4064ac529e1836683ca58b8/cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {url = "https://files.pythonhosted.org/packages/da/ff/ab939e2c7b3f40d851c0f7192c876f1910f3442080c9c846532993ec3cef/cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {url = "https://files.pythonhosted.org/packages/df/02/aef53d4aa43154b829e9707c8c60bab413cd21819c4a36b0d7aaa83e2a61/cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {url = "https://files.pythonhosted.org/packages/e8/ff/c4b7a358526f231efa46a375c959506c87622fb4a2c5726e827c55e6adf2/cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {url = "https://files.pythonhosted.org/packages/ea/be/c4ad40ad441ac847b67c7a37284ae3c58f39f3e638c6b0f85fb662233825/cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {url = "https://files.pythonhosted.org/packages/ed/a3/c5f01988ddb70a187c3e6112152e01696188c9f8a4fa4c68aa330adbb179/cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {url = "https://files.pythonhosted.org/packages/ef/41/19da352d341963d29a33bdb28433ba94c05672fb16155f794fad3fd907b0/cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {url = "https://files.pythonhosted.org/packages/f9/96/fc9e118c47b7adc45a0676f413b4a47554e5f3b6c99b8607ec9726466ef1/cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {url = "https://files.pythonhosted.org/packages/ff/fe/ac46ca7b00e9e4f9c62e7928a11bc9227c86e2ff43526beee00cdfb4f0e8/cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, +] "cfgv 3.3.1" = [ {url = "https://files.pythonhosted.org/packages/6d/82/0a0ebd35bae9981dea55c06f8e6aaf44a49171ad798795c72c6f64cba4c2/cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {url = "https://files.pythonhosted.org/packages/c4/bf/d0d622b660d414a47dc7f0d303791a627663f554345b21250e39e7acb48b/cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] -"charset-normalizer 2.1.1" = [ - {url = "https://files.pythonhosted.org/packages/a1/34/44964211e5410b051e4b8d2869c470ae8a68ae274953b1c7de6d98bbcf94/charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, - {url = "https://files.pythonhosted.org/packages/db/51/a507c856293ab05cdc1db77ff4bc1268ddd39f29e7dc4919aa497f0adbec/charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, -] "colorama 0.4.6" = [ {url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +"cryptography 39.0.0" = [ + {url = "https://files.pythonhosted.org/packages/06/b1/6b6f8dccd1432a6a998e92f75ff235b0f69e8a8c509b5739d673ea2ba548/cryptography-39.0.0-cp36-abi3-win32.whl", hash = "sha256:f671c1bb0d6088e94d61d80c606d65baacc0d374e67bf895148883461cd848de"}, + {url = "https://files.pythonhosted.org/packages/0d/5c/b83623ce6e7d6653d858d5c85916217bb1e66a4c7a2da7051588fd5d9e0a/cryptography-39.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f97109336df5c178ee7c9c711b264c502b905c2d2a29ace99ed761533a3460f"}, + {url = "https://files.pythonhosted.org/packages/12/e3/c46c274cf466b24e5d44df5d5cd31a31ff23e57f074a2bb30931a8c9b01a/cryptography-39.0.0.tar.gz", hash = "sha256:f964c7dcf7802d133e8dbd1565914fa0194f9d683d82411989889ecd701e8adf"}, + {url = "https://files.pythonhosted.org/packages/13/56/7ebf13cfd85f2948480a45937bcc43d6f01edfde99dab47443e72aed564a/cryptography-39.0.0-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:80ee674c08aaef194bc4627b7f2956e5ba7ef29c3cc3ca488cf15854838a8f72"}, + {url = "https://files.pythonhosted.org/packages/2d/62/7c62efcb4a1b1905ad16476f9dcb55a2913bf4dd0049a083390a622901c8/cryptography-39.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f3ed2d864a2fa1666e749fe52fb8e23d8e06b8012e8bd8147c73797c506e86f1"}, + {url = "https://files.pythonhosted.org/packages/2e/90/1fffa1dd2e0894cdd8ef33b7d95de7c4d6de5fb77fb23cd21b24b069047e/cryptography-39.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:887cbc1ea60786e534b00ba8b04d1095f4272d380ebd5f7a7eb4cc274710fad9"}, + {url = "https://files.pythonhosted.org/packages/34/b3/3011b5f6c5cc935113fc58f8b07d42fcdd03e7a76b1c3c8ba27d276e8833/cryptography-39.0.0-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:875aea1039d78557c7c6b4db2fe0e9d2413439f4676310a5f269dd342ca7a717"}, + {url = "https://files.pythonhosted.org/packages/41/3f/8b3676edb61a9d2dc0e78ba9d450ebb75d958f70ed3dea9cb143262c8406/cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:e5d71c5d5bd5b5c3eebcf7c5c2bb332d62ec68921a8c593bea8c394911a005ce"}, + {url = "https://files.pythonhosted.org/packages/43/7d/0d0756853ad357b7f12d63595a8aac66d255ea68061e3c1983f4cfebc73b/cryptography-39.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e0a05aee6a82d944f9b4edd6a001178787d1546ec7c6223ee9a848a7ade92e39"}, + {url = "https://files.pythonhosted.org/packages/44/0a/4170788974aef7baf5eab77947246887c64a0a2c371f769f79259835af89/cryptography-39.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:e324de6972b151f99dc078defe8fb1b0a82c6498e37bff335f5bc6b1e3ab5a1e"}, + {url = "https://files.pythonhosted.org/packages/4e/9e/102aae84e2f1c4733ab76fd311d0b4612699daaba04a3a872567274dc211/cryptography-39.0.0-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bae6c7f4a36a25291b619ad064a30a07110a805d08dc89984f4f441f6c1f3f96"}, + {url = "https://files.pythonhosted.org/packages/54/34/5669347a730ba5f02b89499f03acf4563123fb98b27546d1fadcccf34564/cryptography-39.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7dacfdeee048814563eaaec7c4743c8aea529fe3dd53127313a792f0dadc1773"}, + {url = "https://files.pythonhosted.org/packages/78/23/ca3a4d7cb681fb4b7f9a088e7392f0aa2c1a51017a8a23fff377bb155af7/cryptography-39.0.0-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:c52a1a6f81e738d07f43dab57831c29e57d21c81a942f4602fac7ee21b27f288"}, + {url = "https://files.pythonhosted.org/packages/7a/46/8b58d6b8244ff613ecb983b9428d1168dd0b014a34e13fb19737b9ba1fc1/cryptography-39.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a6915075c6d3a5e1215eab5d99bcec0da26036ff2102a1038401d6ef5bef25b"}, + {url = "https://files.pythonhosted.org/packages/b3/f6/ee2cd6c13d62ecd4f93722327b5f79808d4a243d6d86e6c1058fe361dc68/cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:407cec680e811b4fc829de966f88a7c62a596faa250fc1a4b520a0355b9bc190"}, + {url = "https://files.pythonhosted.org/packages/b4/68/1857c44826171a995e65a11d4b507cd0aa0bace926c2842d7252d8b1dcca/cryptography-39.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f6c0db08d81ead9576c4d94bbb27aed8d7a430fa27890f39084c2d0e2ec6b0df"}, + {url = "https://files.pythonhosted.org/packages/d0/8a/5c567f8a6f12966c46d6f884d259ddf4f8ae908272e8c7c0807a53cdc255/cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ee1fd0de9851ff32dbbb9362a4d833b579b4a6cc96883e8e6d2ff2a6bc7104f"}, + {url = "https://files.pythonhosted.org/packages/dc/05/2cee803d6b83fef95229f9864646ba399f1ebda03333e34c2ddee210aaa1/cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:844ad4d7c3850081dffba91cdd91950038ee4ac525c575509a42d3fc806b83c8"}, + {url = "https://files.pythonhosted.org/packages/e5/9e/ed757a5244649d3400d62967d247af10e85d804882ba56fdf164c3f0c575/cryptography-39.0.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:754978da4d0457e7ca176f58c57b1f9de6556591c19b25b8bcce3c77d314f5eb"}, + {url = "https://files.pythonhosted.org/packages/ee/9f/f9f4e4410e1945550883bc07afc32986dc1e5d59bc327aff88f0ddbf0fb7/cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:fec8b932f51ae245121c4671b4bbc030880f363354b2f0e0bd1366017d891458"}, + {url = "https://files.pythonhosted.org/packages/fa/31/52ccfb7147564fefe83fdbbebc9dbd4c6749663c019a053ab2c83469c47a/cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50386acb40fbabbceeb2986332f0287f50f29ccf1497bae31cf5c3e7b4f4b34f"}, + {url = "https://files.pythonhosted.org/packages/fc/b2/3b946e24de214fc49adeefeea6214bcbc4bce2bd745877f074d1dd13c9a2/cryptography-39.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:76c24dd4fd196a80f9f2f5405a778a8ca132f16b10af113474005635fe7e066c"}, + {url = "https://files.pythonhosted.org/packages/fd/59/bacaaed27787b87b660b7de016e1034a9bf2aaf5031d2b7d085cd83413f3/cryptography-39.0.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ad04f413436b0781f20c52a661660f1e23bcd89a0e9bb1d6d20822d048cf2856"}, +] "distlib 0.3.6" = [ {url = "https://files.pythonhosted.org/packages/58/07/815476ae605bcc5f95c87a62b95e74a1bce0878bc7a3119bc2bf4178f175/distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, {url = "https://files.pythonhosted.org/packages/76/cb/6bbd2b10170ed991cf64e8c8b85e01f2fb38f95d1bc77617569e0b0b26ac/distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, @@ -278,6 +439,18 @@ content_hash = "sha256:d0f252d1f7668578b6920f4df7c430b3b482f4aeadb3f686703debc8f {url = "https://files.pythonhosted.org/packages/7c/37/4fed6f28d8010c791437b808a94f37c4ae58ee3998b653d2c0286a8cc190/filelock-3.8.2-py3-none-any.whl", hash = "sha256:8df285554452285f79c035efb0c861eb33a4bcfa5b7a137016e32e6a90f9792c"}, {url = "https://files.pythonhosted.org/packages/d8/73/292d9ea2370840a163e6dd2d2816a571244e9335e2f6ad957bf0527c492f/filelock-3.8.2.tar.gz", hash = "sha256:7565f628ea56bfcd8e54e42bdc55da899c85c1abfe1b5bcfd147e9188cebb3b2"}, ] +"h11 0.14.0" = [ + {url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] +"httpcore 0.16.2" = [ + {url = "https://files.pythonhosted.org/packages/91/52/93f22e5441539256c0d113faf17e45284aee16eebdd95089e3ca6f480b18/httpcore-0.16.2-py3-none-any.whl", hash = "sha256:52c79095197178856724541e845f2db86d5f1527640d9254b5b8f6f6cebfdee6"}, + {url = "https://files.pythonhosted.org/packages/9b/20/26f6cc4fd00391f8f1c57b0020f5c6eec23904723db04b6f7608e222d815/httpcore-0.16.2.tar.gz", hash = "sha256:c35c5176dc82db732acfd90b581a3062c999a72305df30c0fc8fafd8e4aca068"}, +] +"httpx 0.23.1" = [ + {url = "https://files.pythonhosted.org/packages/8a/df/a3e8b91dfb452e645ef110985a30f0915276a1a2144004c7671c07bb203c/httpx-0.23.1.tar.gz", hash = "sha256:202ae15319be24efe9a8bd4ed4360e68fde7b38bcc2ce87088d416f026667d19"}, + {url = "https://files.pythonhosted.org/packages/e1/74/cdce73069e021ad5913451b86c2707b027975cf302016ca557686d87eb41/httpx-0.23.1-py3-none-any.whl", hash = "sha256:0b9b1f0ee18b9978d637b0776bfd7f54e2ca278e063e3586d8f01cda89e042a8"}, +] "identify 2.5.10" = [ {url = "https://files.pythonhosted.org/packages/24/7f/6812dcfa4ff7f8bd425252eb2bd4ef394477eea1424d3e09bbbfdac94e87/identify-2.5.10.tar.gz", hash = "sha256:dce9e31fee7dbc45fea36a9e855c316b8fbf807e65a862f160840bb5a2bf5dfd"}, {url = "https://files.pythonhosted.org/packages/3e/63/88e087dc7fbe81d16225bdff91fd781bf7cb03101ffc1adfa6b909e36274/identify-2.5.10-py2.py3-none-any.whl", hash = "sha256:fb7c2feaeca6976a3ffa31ec3236a6911fbc51aec9acc111de2aed99f244ade2"}, @@ -318,6 +491,10 @@ content_hash = "sha256:d0f252d1f7668578b6920f4df7c430b3b482f4aeadb3f686703debc8f {url = "https://files.pythonhosted.org/packages/1e/ba/8cf8b88d0e07588818de46877effc9971305541d9421bc6377b06639d135/pre_commit-2.20.0.tar.gz", hash = "sha256:a978dac7bc9ec0bcee55c18a277d553b0f419d259dadb4b9418ff2d00eb43959"}, {url = "https://files.pythonhosted.org/packages/b2/6c/9ccb5213a3d9fd3f8c0fd69d207951901eaef86b7a1a69bcc478364d3072/pre_commit-2.20.0-py2.py3-none-any.whl", hash = "sha256:51a5ba7c480ae8072ecdb6933df22d2f812dc897d5fe848778116129a681aac7"}, ] +"pycparser 2.21" = [ + {url = "https://files.pythonhosted.org/packages/5e/0b/95d387f5f4433cb0f53ff7ad859bd2c6051051cebbb564f139a999ab46de/pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, + {url = "https://files.pythonhosted.org/packages/62/d5/5f610ebe421e85889f2e55e33b7f9a6795bd982198517d912eb1c76e1a53/pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, +] "pytest 7.2.0" = [ {url = "https://files.pythonhosted.org/packages/0b/21/055f39bf8861580b43f845f9e8270c7786fe629b2f8562ff09007132e2e7/pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, {url = "https://files.pythonhosted.org/packages/67/68/a5eb36c3a8540594b6035e6cdae40c1ef1b6a2bfacbecc3d1a544583c078/pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, @@ -368,9 +545,9 @@ content_hash = "sha256:d0f252d1f7668578b6920f4df7c430b3b482f4aeadb3f686703debc8f {url = "https://files.pythonhosted.org/packages/65/e1/824989291d0f01886074fdf9504ba54598f5665bc4dd373b589b87e76608/repath-0.9.0.tar.gz", hash = "sha256:8292139bac6a0e43fd9d70605d4e8daeb25d46672e484ed31a24c7ce0aef0fb7"}, {url = "https://files.pythonhosted.org/packages/87/ed/92e9b8a3ffc562f21df14ef2538f54e911df29730e1f0d79130a4edc86e7/repath-0.9.0-py3-none-any.whl", hash = "sha256:ee079d6c91faeb843274d22d8f786094ee01316ecfe293a1eb6546312bb6a318"}, ] -"requests 2.28.1" = [ - {url = "https://files.pythonhosted.org/packages/a5/61/a867851fd5ab77277495a8709ddda0861b28163c4613b011bc00228cc724/requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, - {url = "https://files.pythonhosted.org/packages/ca/91/6d9b8ccacd0412c08820f72cebaa4f0c0441b5cda699c90f618b6f8a1b42/requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, +"rfc3986 1.5.0" = [ + {url = "https://files.pythonhosted.org/packages/79/30/5b1b6c28c105629cc12b33bdcbb0b11b5bb1880c6cfbd955f9e792921aa8/rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, + {url = "https://files.pythonhosted.org/packages/c4/e5/63ca2c4edf4e00657584608bee1001302bbf8c5f569340b78304f2f446cb/rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, ] "setuptools 65.6.3" = [ {url = "https://files.pythonhosted.org/packages/b6/21/cb9a8d0b2c8597c83fce8e9c02884bce3d4951e41e807fc35791c6b23d9a/setuptools-65.6.3.tar.gz", hash = "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75"}, @@ -380,6 +557,10 @@ content_hash = "sha256:d0f252d1f7668578b6920f4df7c430b3b482f4aeadb3f686703debc8f {url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, ] +"sniffio 1.3.0" = [ + {url = "https://files.pythonhosted.org/packages/c3/a0/5dba8ed157b0136607c7f2151db695885606968d1fae123dc3391e0cfdbf/sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {url = "https://files.pythonhosted.org/packages/cd/50/d49c388cae4ec10e8109b1b833fd265511840706808576df3ada99ecb0ac/sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] "toml 0.10.2" = [ {url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, @@ -392,10 +573,6 @@ content_hash = "sha256:d0f252d1f7668578b6920f4df7c430b3b482f4aeadb3f686703debc8f {url = "https://files.pythonhosted.org/packages/0b/8e/f1a0a5a76cfef77e1eb6004cb49e5f8d72634da638420b9ea492ce8305e8/typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, {url = "https://files.pythonhosted.org/packages/e3/a7/8f4e456ef0adac43f452efc2d0e4b242ab831297f1bac60ac815d37eb9cf/typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, ] -"urllib3 1.26.13" = [ - {url = "https://files.pythonhosted.org/packages/65/0c/cc6644eaa594585e5875f46f3c83ee8762b647b51fc5b0fb253a242df2dc/urllib3-1.26.13-py2.py3-none-any.whl", hash = "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc"}, - {url = "https://files.pythonhosted.org/packages/c2/51/32da03cf19d17d46cce5c731967bf58de9bd71db3a379932f53b094deda4/urllib3-1.26.13.tar.gz", hash = "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8"}, -] "virtualenv 20.17.1" = [ {url = "https://files.pythonhosted.org/packages/18/a2/7931d40ecb02b5236a34ac53770f2f6931e3082b7a7dafe915d892d749d6/virtualenv-20.17.1-py3-none-any.whl", hash = "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4"}, {url = "https://files.pythonhosted.org/packages/7b/19/65f13cff26c8cc11fdfcb0499cd8f13388dd7b35a79a376755f152b42d86/virtualenv-20.17.1.tar.gz", hash = "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"}, @@ -434,6 +611,77 @@ content_hash = "sha256:d0f252d1f7668578b6920f4df7c430b3b482f4aeadb3f686703debc8f {url = "https://files.pythonhosted.org/packages/75/af/1d13b93e7a21aca7f8ab8645fcfcfad21fc39716dc9dce5dc2a97f73ff78/websocket-client-1.4.2.tar.gz", hash = "sha256:d6e8f90ca8e2dd4e8027c4561adeb9456b54044312dba655e7cae652ceb9ae59"}, {url = "https://files.pythonhosted.org/packages/78/d5/2b5719b738791cd798e8f097eba4bb093ff5efca5cef2f3d37a72daa111f/websocket_client-1.4.2-py3-none-any.whl", hash = "sha256:d6b06432f184438d99ac1f456eaf22fe1ade524c3dd16e661142dc54e9cba574"}, ] +"websockets 10.4" = [ + {url = "https://files.pythonhosted.org/packages/00/15/611ddaca66937f77aa5021e97c9bec61e6a30668b75db3707713b69b3b88/websockets-10.4-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:82ff5e1cae4e855147fd57a2863376ed7454134c2bf49ec604dfe71e446e2193"}, + {url = "https://files.pythonhosted.org/packages/03/e2/7784912651a299a5e060656e6368946ae4c1da63f01236f7d650e8070cf8/websockets-10.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b343f521b047493dc4022dd338fc6db9d9282658862756b4f6fd0e996c1380e1"}, + {url = "https://files.pythonhosted.org/packages/09/35/2b8ed52dc995507476ebbb7a91a0c5ed80fd80fa0a840f422ac25c722dbf/websockets-10.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:e23173580d740bf8822fd0379e4bf30aa1d5a92a4f252d34e893070c081050df"}, + {url = "https://files.pythonhosted.org/packages/0c/56/b2d373ed19b4e7b6c5c7630d598ba10473fa6131e67e69590214ab18bc09/websockets-10.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e0cb5cc6ece6ffa75baccfd5c02cffe776f3f5c8bf486811f9d3ea3453676ce8"}, + {url = "https://files.pythonhosted.org/packages/0c/f0/195097822f8edc4ffa355f6463a1890928577517382c0baededc760f9397/websockets-10.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fe10ddc59b304cb19a1bdf5bd0a7719cbbc9fbdd57ac80ed436b709fcf889106"}, + {url = "https://files.pythonhosted.org/packages/14/88/81c08fb3418c5aedf3776333f29443599729509a4f673d6598dd769d3d6b/websockets-10.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d58804e996d7d2307173d56c297cf7bc132c52df27a3efaac5e8d43e36c21c48"}, + {url = "https://files.pythonhosted.org/packages/17/e4/3bdc2ea97d7da70d9f184051dcd40f27c849ded517ea9bab70df677a6b23/websockets-10.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5c1289596042fad2cdceb05e1ebf7aadf9995c928e0da2b7a4e99494953b1b94"}, + {url = "https://files.pythonhosted.org/packages/19/a3/02ce75ffca3ef147cc0f44647c67acb3171b5a09910b5b9f083b5ca395a6/websockets-10.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:90fcf8929836d4a0e964d799a58823547df5a5e9afa83081761630553be731f9"}, + {url = "https://files.pythonhosted.org/packages/1c/4b/cab8fed34c3a29d4594ff77234f6e6b45feb35331f1c12fccf92ca5486dd/websockets-10.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62e627f6b6d4aed919a2052efc408da7a545c606268d5ab5bfab4432734b82b4"}, + {url = "https://files.pythonhosted.org/packages/1d/06/5ecd0434cf35f92ca9ce80e38a3ac9bf5422ace9488693c3900e2f1c7fa0/websockets-10.4-cp37-cp37m-win32.whl", hash = "sha256:8a5cc00546e0a701da4639aa0bbcb0ae2bb678c87f46da01ac2d789e1f2d2038"}, + {url = "https://files.pythonhosted.org/packages/1e/76/163a18626001465a309bf74b6aeb301d7092e304637fe00f89d7efc75c44/websockets-10.4-cp310-cp310-win_amd64.whl", hash = "sha256:8dc96f64ae43dde92530775e9cb169979f414dcf5cff670455d81a6823b42089"}, + {url = "https://files.pythonhosted.org/packages/20/7a/bd0ce7ac1cfafc76c84d6e8051bcbd0f7def8e45207230833bd6ff77a41d/websockets-10.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ba089c499e1f4155d2a3c2a05d2878a3428cf321c848f2b5a45ce55f0d7d310c"}, + {url = "https://files.pythonhosted.org/packages/25/a7/4e32f8edfc26339d8d170fe539e0b83a329c42d974dacfe07a0566390aef/websockets-10.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33d69ca7612f0ddff3316b0c7b33ca180d464ecac2d115805c044bf0a3b0d032"}, + {url = "https://files.pythonhosted.org/packages/27/bb/6327e8c7d4dd7d5b450b409a461be278968ce05c54da13da581ac87661db/websockets-10.4-cp311-cp311-win_amd64.whl", hash = "sha256:a7a240d7a74bf8d5cb3bfe6be7f21697a28ec4b1a437607bae08ac7acf5b4882"}, + {url = "https://files.pythonhosted.org/packages/29/33/dd88aefeabc9dddb4f48c9e15c6c2554dfb6b4cf8d8f1b4de4d12ba997de/websockets-10.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0cff816f51fb33c26d6e2b16b5c7d48eaa31dae5488ace6aae468b361f422b63"}, + {url = "https://files.pythonhosted.org/packages/2b/cb/d394efe7b0ee6cdeffac28a1cb054e42f9f95974885ca3bcd6fceb0acde1/websockets-10.4-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff64a1d38d156d429404aaa84b27305e957fd10c30e5880d1765c9480bea490f"}, + {url = "https://files.pythonhosted.org/packages/2e/dd/521f0574bed6d08ce5e0acd5893ae418c0a81ef55eb4c960aedac9cbd929/websockets-10.4-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b627c266f295de9dea86bd1112ed3d5fafb69a348af30a2422e16590a8ecba13"}, + {url = "https://files.pythonhosted.org/packages/33/3a/72c9d733d676447da2c89a35c694f779a9a360cff51ee0f90bb562d80cd4/websockets-10.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7d27a7e34c313b3a7f91adcd05134315002aaf8540d7b4f90336beafaea6217c"}, + {url = "https://files.pythonhosted.org/packages/36/8f/6dd75723ea67d54dec3a597ad781642c0febe8d51f233b95347981c0e549/websockets-10.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9bc42e8402dc5e9905fb8b9649f57efcb2056693b7e88faa8fb029256ba9c68c"}, + {url = "https://files.pythonhosted.org/packages/37/02/ef21ca4698c2fd950250e5ac397fd07b0c9f16bbd073d0ea64c25baef9c1/websockets-10.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7c584f366f46ba667cfa66020344886cf47088e79c9b9d39c84ce9ea98aaa331"}, + {url = "https://files.pythonhosted.org/packages/3e/a5/e4535867a96bb07000c54172e1be82cd0b3a95339244cac1d400f8ba9b64/websockets-10.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185929b4808b36a79c65b7865783b87b6841e852ef5407a2fb0c03381092fa3b"}, + {url = "https://files.pythonhosted.org/packages/47/4d/f2e28f112302d3bc794b74ae64656255161d8223f4d47bd17d40cbb3629e/websockets-10.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:56029457f219ade1f2fc12a6504ea61e14ee227a815531f9738e41203a429112"}, + {url = "https://files.pythonhosted.org/packages/47/58/69435f1479acb56b3678905b5f2be57908a201c28465d4368d91f52cad76/websockets-10.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dd9becd5fe29773d140d68d607d66a38f60e31b86df75332703757ee645b6faf"}, + {url = "https://files.pythonhosted.org/packages/4a/39/3b6b64f775f1f4f5de6eb909d72f3f794f453730b5b3176fa5021ff334ba/websockets-10.4-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9b27d6c1c6cd53dc93614967e9ce00ae7f864a2d9f99fe5ed86706e1ecbf485"}, + {url = "https://files.pythonhosted.org/packages/4d/6f/2388f9304cdaa0215b6388f837c6dbfe6d63ac1bba8c196e3b14eea1831e/websockets-10.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38ea7b82bfcae927eeffc55d2ffa31665dc7fec7b8dc654506b8e5a518eb4d50"}, + {url = "https://files.pythonhosted.org/packages/4e/8b/854b3625cc5130e4af8a10a7502c2f6c16d1bd107ff009394127a2f8abb3/websockets-10.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:40e826de3085721dabc7cf9bfd41682dadc02286d8cf149b3ad05bff89311e4f"}, + {url = "https://files.pythonhosted.org/packages/57/d7/df17197565e8874f0a77f8211304169ad4f39ffa3e8c008a7b0bf187a238/websockets-10.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5fc088b7a32f244c519a048c170f14cf2251b849ef0e20cbbb0fdf0fdaf556f"}, + {url = "https://files.pythonhosted.org/packages/5a/87/dea889793d2d0958be254fc86dac528d97de9354d16fcdbcbad259750014/websockets-10.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00213676a2e46b6ebf6045bc11d0f529d9120baa6f58d122b4021ad92adabd41"}, + {url = "https://files.pythonhosted.org/packages/5d/3c/fc1725524e48f624df77f5998b1c7070fdddec3ae67a2ffbc99ffd116269/websockets-10.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f55b5905705725af31ccef50e55391621532cd64fbf0bc6f4bac935f0fccec46"}, + {url = "https://files.pythonhosted.org/packages/60/3a/6dccbe2725d13c398b90cbebeea684cda7792e6d874f96417db900556ad0/websockets-10.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47a2964021f2110116cc1125b3e6d87ab5ad16dea161949e7244ec583b905bb4"}, + {url = "https://files.pythonhosted.org/packages/62/76/c2411e634979cc6e812ef2a96aa295545cfcbc9566b298db09f3f4639d62/websockets-10.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:edc344de4dac1d89300a053ac973299e82d3db56330f3494905643bb68801269"}, + {url = "https://files.pythonhosted.org/packages/63/f2/ec4c59b4f91936eb2a5ddcf2f7e57184acbce5122d5d83911c5a47f25144/websockets-10.4-cp38-cp38-win_amd64.whl", hash = "sha256:48c08473563323f9c9debac781ecf66f94ad5a3680a38fe84dee5388cf5acaf6"}, + {url = "https://files.pythonhosted.org/packages/68/bd/c8bd8354fc629863a2db39c9182d40297f47dfb2ed3e178bc83041ce044b/websockets-10.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc0b82d728fe21a0d03e65f81980abbbcb13b5387f733a1a870672c5be26edab"}, + {url = "https://files.pythonhosted.org/packages/68/ec/3267f8bbe8a4a5e181ab3fc67cc137f0966ab9e9a4da14ffc603f320b9e6/websockets-10.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00c870522cdb69cd625b93f002961ffb0c095394f06ba8c48f17eef7c1541f96"}, + {url = "https://files.pythonhosted.org/packages/71/93/5a4f408177e43d84274e1c08cbea3e50ad80db654dc25a0bba79dbdc00b4/websockets-10.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f72e5cd0f18f262f5da20efa9e241699e0cf3a766317a17392550c9ad7b37d8"}, + {url = "https://files.pythonhosted.org/packages/75/18/155c3582fd69b60d9c490fb0e64e37269c55d5873cbcb37f83e2d3feb078/websockets-10.4-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389f8dbb5c489e305fb113ca1b6bdcdaa130923f77485db5b189de343a179393"}, + {url = "https://files.pythonhosted.org/packages/77/65/d7c73e62cf19f068850ddab548837329dab9c023567f5834747f61cdc747/websockets-10.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45ec8e75b7dbc9539cbfafa570742fe4f676eb8b0d3694b67dabe2f2ceed8aa6"}, + {url = "https://files.pythonhosted.org/packages/85/dc/549a807a53c13fd4a8dac286f117a7a71260defea9ec0c05d6027f2ae273/websockets-10.4.tar.gz", hash = "sha256:eef610b23933c54d5d921c92578ae5f89813438fded840c2e9809d378dc765d3"}, + {url = "https://files.pythonhosted.org/packages/86/8e/390e0e3db702c55d31ca3999c622bb3b8b480c306c1bdee6a2da44b13b1b/websockets-10.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:84bc2a7d075f32f6ed98652db3a680a17a4edb21ca7f80fe42e38753a58ee02b"}, + {url = "https://files.pythonhosted.org/packages/88/00/9776e2626a30e3455a830665e50cf40f5d34a4134272b3138a637afa38a7/websockets-10.4-cp39-cp39-win_amd64.whl", hash = "sha256:bbccd847aa0c3a69b5f691a84d2341a4f8a629c6922558f2a70611305f902d74"}, + {url = "https://files.pythonhosted.org/packages/88/97/d70e2d528b9ffe759134e5db6b1424b61cd61fd1c4471b178c76e01f41af/websockets-10.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:74de2b894b47f1d21cbd0b37a5e2b2392ad95d17ae983e64727e18eb281fe7cb"}, + {url = "https://files.pythonhosted.org/packages/8a/1e/8f34d7ee924dc7a624c1e14f43209484cb5eccb58e892285d45551729a95/websockets-10.4-cp39-cp39-win32.whl", hash = "sha256:c94ae4faf2d09f7c81847c63843f84fe47bf6253c9d60b20f25edfd30fb12588"}, + {url = "https://files.pythonhosted.org/packages/90/e1/22e43e9a1fbc9ddf4a0317b231e2e28eddfbe8804b7ca4a9f7fba7033b17/websockets-10.4-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:942de28af58f352a6f588bc72490ae0f4ccd6dfc2bd3de5945b882a078e4e179"}, + {url = "https://files.pythonhosted.org/packages/93/7b/72134e4c75002e311c072f0665fe45f7321d614c5c65181888faddd616e9/websockets-10.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fc8709c00704194213d45e455adc106ff9e87658297f72d544220e32029cd3d"}, + {url = "https://files.pythonhosted.org/packages/a0/92/aa8d1ba3a7e3e6cf6d5d1c929530a40138667ea60454bf5c0fff3b93cae2/websockets-10.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c57e4c1349fbe0e446c9fa7b19ed2f8a4417233b6984277cce392819123142d3"}, + {url = "https://files.pythonhosted.org/packages/a1/6f/60e5f6e114b6077683d74da5df0d4af647a9e6d2a18b4698f577b2cb7c14/websockets-10.4-cp310-cp310-win32.whl", hash = "sha256:b029fb2032ae4724d8ae8d4f6b363f2cc39e4c7b12454df8df7f0f563ed3e61a"}, + {url = "https://files.pythonhosted.org/packages/a1/f6/83da14582fbb0496c47a4c039bd6e802886a0c300e9795c0f839fd1498e3/websockets-10.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ae5e95cfb53ab1da62185e23b3130e11d64431179debac6dc3c6acf08760e9b1"}, + {url = "https://files.pythonhosted.org/packages/ab/41/ed2fecb228c1f25cea03fce4a22a86f7771a10875d5762e777e943bb7d68/websockets-10.4-cp37-cp37m-win_amd64.whl", hash = "sha256:a9f9a735deaf9a0cadc2d8c50d1a5bcdbae8b6e539c6e08237bc4082d7c13f28"}, + {url = "https://files.pythonhosted.org/packages/b0/fc/a818cddc63589e12d5eff9b51a59aad82e2adf35279493248a3742c41f85/websockets-10.4-cp311-cp311-win32.whl", hash = "sha256:b9968694c5f467bf67ef97ae7ad4d56d14be2751000c1207d31bf3bb8860bae8"}, + {url = "https://files.pythonhosted.org/packages/b1/8f/dbffb63e7da0ada24e9ef8802c439169e0ed9a7ef8f6049874e6cbfc7919/websockets-10.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d15c968ea7a65211e084f523151dbf8ae44634de03c801b8bd070b74e85033"}, + {url = "https://files.pythonhosted.org/packages/b4/91/c460f5164af303b31f58362935f7b8ed1750e3b8fbcb900da4b0661532a8/websockets-10.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:931c039af54fc195fe6ad536fde4b0de04da9d5916e78e55405436348cfb0e56"}, + {url = "https://files.pythonhosted.org/packages/bb/5c/7dc1f604688f43168ef17313055e048c755a29afde821f7e0b19bd3a180f/websockets-10.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a4162139374a49eb18ef5b2f4da1dd95c994588f5033d64e0bbfda4b6b6fcf"}, + {url = "https://files.pythonhosted.org/packages/c5/01/145d2883dfeffedf541a7c95bb26f8d8b5ddca84a7c8f671ec3b878ae7cd/websockets-10.4-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09a1814bb15eff7069e51fed0826df0bc0702652b5cb8f87697d469d79c23576"}, + {url = "https://files.pythonhosted.org/packages/c6/41/07f39745017af5381aeb6c1d8c6509aa1861193c948648d4aaf4d0637915/websockets-10.4-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:3d3cac3e32b2c8414f4f87c1b2ab686fa6284a980ba283617404377cd448f631"}, + {url = "https://files.pythonhosted.org/packages/cc/19/2f003f9f81c0fab2eabb81d8fc2fce5fb5b5714f1b4abfe897cb209e031d/websockets-10.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3f0b61c45c3fa9a349cf484962c559a8a1d80dae6977276df8fd1fa5e3cb8c"}, + {url = "https://files.pythonhosted.org/packages/d1/60/0a6cb94e25b981e428c1cdcc2b0a406ac6e1dfc78d8a81c8a4ee7510e853/websockets-10.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e789376b52c295c4946403bd0efecf27ab98f05319df4583d3c48e43c7342c2f"}, + {url = "https://files.pythonhosted.org/packages/d1/c6/9489869aa591e6a8941b0af2302f8383e199e90477559a510713d41bfa45/websockets-10.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f2c38d588887a609191d30e902df2a32711f708abfd85d318ca9b367258cfd0c"}, + {url = "https://files.pythonhosted.org/packages/d4/1a/2e4afd95abd33bd6ad77042270f8eee3697e07cdd749c068bff08bba2022/websockets-10.4-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d210abe51b5da0ffdbf7b43eed0cfdff8a55a1ab17abbec4301c9ff077dd0342"}, + {url = "https://files.pythonhosted.org/packages/d5/5d/d0b039f0db0bb1fea93437721cf3cd8a244ad02a86960c38a3853d5e1fab/websockets-10.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f38706e0b15d3c20ef6259fd4bc1700cd133b06c3c1bb108ffe3f8947be15fa"}, + {url = "https://files.pythonhosted.org/packages/d6/7c/79ea4e7f56dfe7f703213000bbbd29b70cef2666698d98b66ce1af43caee/websockets-10.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0154f7691e4fe6c2b2bc275b5701e8b158dae92a1ab229e2b940efe11905dff4"}, + {url = "https://files.pythonhosted.org/packages/d7/f9/f64ec37da654351b212e5534b0e31703ed80d2a6acb6b8c1b1373fafa876/websockets-10.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c6d2264f485f0b53adf22697ac11e261ce84805c232ed5dbe6b1bcb84b00ff0"}, + {url = "https://files.pythonhosted.org/packages/da/0b/a501ed176c69b51ca83f4186bad80bba9b59ab354fd8954d7d36cb2ec47f/websockets-10.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3a686ecb4aa0d64ae60c9c9f1a7d5d46cab9bfb5d91a2d303d00e2cd4c4c5cc"}, + {url = "https://files.pythonhosted.org/packages/e0/8d/7bffabd3f10a88cd68080669b33f407471283becf7e5cb4f0143b117211d/websockets-10.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:932af322458da7e4e35df32f050389e13d3d96b09d274b22a7aa1808f292fee4"}, + {url = "https://files.pythonhosted.org/packages/e6/94/cb97e5a9d019e473a37317a740852850ef09e14c02621dd86a898ec90f7a/websockets-10.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:884be66c76a444c59f801ac13f40c76f176f1bfa815ef5b8ed44321e74f1600b"}, + {url = "https://files.pythonhosted.org/packages/e9/48/a0751eafbeab06866fc70a66f7dfa08422cb96113af9138e526e7b106f14/websockets-10.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:dd500e0a5e11969cdd3320935ca2ff1e936f2358f9c2e61f100a1660933320ea"}, + {url = "https://files.pythonhosted.org/packages/ec/ba/74b4b92cc41ffc4cfa791fb9f8e8ab7c4d9bf84e54a5bef12ab23eb54880/websockets-10.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:da39dd03d130162deb63da51f6e66ed73032ae62e74aaccc4236e30edccddbb0"}, + {url = "https://files.pythonhosted.org/packages/f8/f0/437187175182beed10246f53ef9793a5f6e087ce71ee25b64fdb12e396e0/websockets-10.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4239b6027e3d66a89446908ff3027d2737afc1a375f8fd3eea630a4842ec9a0c"}, + {url = "https://files.pythonhosted.org/packages/f9/15/ab0e9155700d3037ffe4a146a719f3e68ee025c9d45d6a39b027e928db52/websockets-10.4-cp38-cp38-win32.whl", hash = "sha256:db3c336f9eda2532ec0fd8ea49fef7a8df8f6c804cdf4f39e5c5c0d4a4ad9a7a"}, + {url = "https://files.pythonhosted.org/packages/fd/42/07f31d9f9e142b38cde8d3ea0c8ea1bacf9bc366f2f573eca57086e9f2a6/websockets-10.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:05a7233089f8bd355e8cbe127c2e8ca0b4ea55467861906b80d2ebc7db4d6b72"}, +] "zipp 3.11.0" = [ {url = "https://files.pythonhosted.org/packages/8e/b3/8b16a007184714f71157b1a71bbe632c5d66dd43bc8152b3c799b13881e1/zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"}, {url = "https://files.pythonhosted.org/packages/d8/20/256eb3f3f437c575fb1a2efdce5e801a5ce3162ea8117da96c43e6ee97d8/zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"}, diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 91546d9e5..92dcad2a8 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -12,8 +12,9 @@ dependencies = [ 'typing_extensions; python_version < "3.8"', "repath>=0.9.0", "watchdog>=2.1.9", - "requests>=2.28.1", "oauthlib>=3.2.0", + "websockets>=10.4", + "httpx>=0.23.1", "packaging>=22.0", ] requires-python = ">=3.7" @@ -38,6 +39,7 @@ tests = [ ] dev = [ "pre-commit>=2.17.0", + "cryptography>=39.0.0", ] [project.scripts] diff --git a/sdk/python/tests/test_moving_children.py b/sdk/python/tests/test_moving_children.py index a4be138c6..7132580ef 100644 --- a/sdk/python/tests/test_moving_children.py +++ b/sdk/python/tests/test_moving_children.py @@ -12,8 +12,9 @@ def test_moving_children(): index = [] added_controls = [] + removed_controls = [] commands = [] - c.build_update_commands(index, added_controls, commands, False) + c.build_update_commands(index, commands, added_controls, removed_controls, False) def replace_controls(c): random.shuffle(c.controls) @@ -24,7 +25,9 @@ def replace_controls(c): for ctrl in c.controls: # print(ctrl._Control__uid) r.add(ctrl._Control__uid) - c.build_update_commands(index, added_controls, commands, False) + c.build_update_commands( + index, commands, added_controls, removed_controls, False + ) for cmd in commands: if cmd.name == "add": for sub_cmd in cmd.commands: