codekingpro/portable-devtools
115k
1import socket2import struct3import threading4import time5from typing import Optional, Union6 7# websocket modules8from ._abnf import ABNF, STATUS_NORMAL, continuous_frame, frame_buffer9from ._exceptions import (10 WebSocketProtocolException,11 WebSocketConnectionClosedException,12 WebSocketTimeoutException,13)14from ._handshake import SUPPORTED_REDIRECT_STATUSES, handshake15from ._http import connect, proxy_info16from ._logging import debug, error, trace, isEnabledForError, isEnabledForTrace17from ._socket import getdefaulttimeout, recv, send, sock_opt18from ._ssl_compat import ssl19from ._utils import NoLock20from ._dispatcher import DispatcherBase, WrappedDispatcher21 22"""23_core.py24websocket - WebSocket client library for Python25 26Copyright 2025 engn33r27 28Licensed under the Apache License, Version 2.0 (the "License");29you may not use this file except in compliance with the License.30You may obtain a copy of the License at31 32 http://www.apache.org/licenses/LICENSE-2.033 34Unless required by applicable law or agreed to in writing, software35distributed under the License is distributed on an "AS IS" BASIS,36WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.37See the License for the specific language governing permissions and38limitations under the License.39"""40 41__all__ = ["WebSocket", "create_connection"]42 43 44class WebSocket:45 """46 Low level WebSocket interface.47 48 This class is based on the WebSocket protocol `draft-hixie-thewebsocketprotocol-76 <http://tools.ietf.org/html/draft-hixie-thewebsocketprotocol-76>`_49 50 We can connect to the websocket server and send/receive data.51 The following example is an echo client.52 53 >>> import websocket54 >>> ws = websocket.WebSocket()55 >>> ws.connect("ws://echo.websocket.events")56 >>> ws.recv()57 'echo.websocket.events sponsored by Lob.com'58 >>> ws.send("Hello, Server")59 1960 >>> ws.recv()61 'Hello, Server'62 >>> ws.close()63 64 Parameters65 ----------66 get_mask_key: func67 A callable function to get new mask keys, see the68 WebSocket.set_mask_key's docstring for more information.69 sockopt: tuple70 Values for socket.setsockopt.71 sockopt must be tuple and each element is argument of sock.setsockopt.72 sslopt: dict73 Optional dict object for ssl socket options. See FAQ for details.74 fire_cont_frame: bool75 Fire recv event for each cont frame. Default is False.76 enable_multithread: bool77 If set to True, lock send method.78 skip_utf8_validation: bool79 Skip utf8 validation.80 """81 82 def __init__(83 self,84 get_mask_key=None,85 sockopt=None,86 sslopt=None,87 fire_cont_frame: bool = False,88 enable_multithread: bool = True,89 skip_utf8_validation: bool = False,90 dispatcher: Union[DispatcherBase, WrappedDispatcher] = None,91 **_,92 ):93 """94 Initialize WebSocket object.95 96 Parameters97 ----------98 sslopt: dict99 Optional dict object for ssl socket options. See FAQ for details.100 """101 self.sock_opt = sock_opt(sockopt, sslopt)102 self.handshake_response = None103 self.sock: Optional[socket.socket] = None104 105 self.connected = False106 self.get_mask_key = get_mask_key107 # These buffer over the build-up of a single frame.108 self.frame_buffer = frame_buffer(self._recv, skip_utf8_validation)109 self.cont_frame = continuous_frame(fire_cont_frame, skip_utf8_validation)110 self.dispatcher = dispatcher111 112 if enable_multithread:113 self.lock = threading.Lock()114 self.readlock = threading.Lock()115 else:116 self.lock = NoLock() # type: ignore[assignment]117 self.readlock = NoLock() # type: ignore[assignment]118 119 def __iter__(self):120 """121 Allow iteration over websocket, implying sequential `recv` executions.122 """123 while True:124 yield self.recv()125 126 def __next__(self):127 return self.recv()128 129 def next(self):130 return self.__next__()131 132 def fileno(self):133 return self.sock.fileno()134 135 def set_mask_key(self, func):136 """137 Set function to create mask key. You can customize mask key generator.138 Mainly, this is for testing purpose.139 140 Parameters141 ----------142 func: func143 callable object. the func takes 1 argument as integer.144 The argument means length of mask key.145 This func must return string(byte array),146 which length is argument specified.147 """148 self.get_mask_key = func149 150 def gettimeout(self) -> Optional[Union[float, int]]:151 """152 Get the websocket timeout (in seconds) as an int or float153 154 Returns155 ----------156 timeout: int or float157 returns timeout value (in seconds). This value could be either float/integer.158 """159 return self.sock_opt.timeout160 161 def settimeout(self, timeout: Optional[Union[float, int]]):162 """163 Set the timeout to the websocket.164 165 Parameters166 ----------167 timeout: int or float168 timeout time (in seconds). This value could be either float/integer.169 """170 self.sock_opt.timeout = timeout171 if self.sock:172 self.sock.settimeout(timeout)173 174 timeout = property(gettimeout, settimeout)175 176 def getsubprotocol(self):177 """178 Get subprotocol179 """180 if self.handshake_response:181 return self.handshake_response.subprotocol182 else:183 return None184 185 subprotocol = property(getsubprotocol)186 187 def getstatus(self):188 """189 Get handshake status190 """191 if self.handshake_response:192 return self.handshake_response.status193 else:194 return None195 196 status = property(getstatus)197 198 def getheaders(self):199 """200 Get handshake response header201 """202 if self.handshake_response:203 return self.handshake_response.headers204 else:205 return None206 207 def is_ssl(self):208 try:209 return isinstance(self.sock, ssl.SSLSocket)210 except (AttributeError, NameError):211 return False212 213 headers = property(getheaders)214 215 def connect(self, url, **options):216 """217 Connect to url. url is websocket url scheme.218 ie. ws://host:port/resource219 You can customize using 'options'.220 If you set "header" list object, you can set your own custom header.221 222 >>> ws = WebSocket()223 >>> ws.connect("ws://echo.websocket.events",224 ... header=["User-Agent: MyProgram",225 ... "x-custom: header"])226 227 Parameters228 ----------229 header: list or dict230 Custom http header list or dict.231 cookie: str232 Cookie value.233 origin: str234 Custom origin url.235 connection: str236 Custom connection header value.237 Default value "Upgrade" set in _handshake.py238 suppress_origin: bool239 Suppress outputting origin header.240 host: str241 Custom host header string.242 timeout: int or float243 Socket timeout time. This value is an integer or float.244 If you set None for this value, it means "use default_timeout value"245 http_proxy_host: str246 HTTP proxy host name.247 http_proxy_port: str or int248 HTTP proxy port. Default is 80.249 http_no_proxy: list250 Whitelisted host names that don't use the proxy.251 http_proxy_auth: tuple252 HTTP proxy auth information. Tuple of username and password. Default is None.253 http_proxy_timeout: int or float254 HTTP proxy timeout, default is 60 sec as per python-socks.255 redirect_limit: int256 Number of redirects to follow.257 subprotocols: list258 List of available subprotocols. Default is None.259 socket: socket260 Pre-initialized stream socket.261 """262 self.sock_opt.timeout = options.get("timeout", self.sock_opt.timeout)263 self.sock, addrs = connect(264 url, self.sock_opt, proxy_info(**options), options.pop("socket", None)265 )266 267 try:268 self.handshake_response = handshake(self.sock, url, *addrs, **options)269 for _ in range(options.pop("redirect_limit", 3)):270 if self.handshake_response.status in SUPPORTED_REDIRECT_STATUSES:271 url = self.handshake_response.headers["location"]272 self.sock.close()273 self.sock, addrs = connect(274 url,275 self.sock_opt,276 proxy_info(**options),277 options.pop("socket", None),278 )279 self.handshake_response = handshake(280 self.sock, url, *addrs, **options281 )282 self.connected = True283 except:284 if self.sock:285 self.sock.close()286 self.sock = None287 raise288 289 def send(self, payload: Union[bytes, str], opcode: int = ABNF.OPCODE_TEXT) -> int:290 """291 Send the data as string.292 293 Parameters294 ----------295 payload: str296 Payload must be utf-8 string or unicode,297 If the opcode is OPCODE_TEXT.298 Otherwise, it must be string(byte array).299 opcode: int300 Operation code (opcode) to send.301 """302 303 frame = ABNF.create_frame(payload, opcode)304 return self.send_frame(frame)305 306 def send_text(self, text_data: str) -> int:307 """308 Sends UTF-8 encoded text.309 """310 return self.send(text_data, ABNF.OPCODE_TEXT)311 312 def send_bytes(self, data: Union[bytes, bytearray]) -> int:313 """314 Sends a sequence of bytes.315 """316 return self.send(data, ABNF.OPCODE_BINARY)317 318 def send_frame(self, frame) -> int:319 """320 Send the data frame.321 322 >>> ws = create_connection("ws://echo.websocket.events")323 >>> frame = ABNF.create_frame("Hello", ABNF.OPCODE_TEXT)324 >>> ws.send_frame(frame)325 >>> cont_frame = ABNF.create_frame("My name is ", ABNF.OPCODE_CONT, 0)326 >>> ws.send_frame(frame)327 >>> cont_frame = ABNF.create_frame("Foo Bar", ABNF.OPCODE_CONT, 1)328 >>> ws.send_frame(frame)329 330 Parameters331 ----------332 frame: ABNF frame333 frame data created by ABNF.create_frame334 """335 if self.get_mask_key:336 frame.get_mask_key = self.get_mask_key337 data = frame.format()338 length = len(data)339 if isEnabledForTrace():340 trace(f"++Sent raw: {repr(data)}")341 trace(f"++Sent decoded: {frame.__str__()}")342 with self.lock:343 while data:344 bytes_sent = self._send(data)345 data = data[bytes_sent:]346 347 return length348 349 def send_binary(self, payload: bytes) -> int:350 """351 Send a binary message (OPCODE_BINARY).352 353 Parameters354 ----------355 payload: bytes356 payload of message to send.357 """358 return self.send(payload, ABNF.OPCODE_BINARY)359 360 def ping(self, payload: Union[str, bytes] = ""):361 """362 Send ping data.363 364 Parameters365 ----------366 payload: str367 data payload to send server.368 """369 if isinstance(payload, str):370 payload = payload.encode("utf-8")371 self.send(payload, ABNF.OPCODE_PING)372 373 def pong(self, payload: Union[str, bytes] = ""):374 """375 Send pong data.376 377 Parameters378 ----------379 payload: str380 data payload to send server.381 """382 if isinstance(payload, str):383 payload = payload.encode("utf-8")384 self.send(payload, ABNF.OPCODE_PONG)385 386 def recv(self) -> Union[str, bytes]:387 """388 Receive string data(byte array) from the server.389 390 Returns391 ----------392 data: string (byte array) value.393 """394 with self.readlock:395 opcode, data = self.recv_data()396 if opcode == ABNF.OPCODE_TEXT:397 data_received: Union[bytes, str] = data398 if isinstance(data_received, bytes):399 return data_received.decode("utf-8")400 elif isinstance(data_received, str):401 return data_received402 elif opcode == ABNF.OPCODE_BINARY:403 data_binary: bytes = data404 return data_binary405 else:406 return ""407 408 def recv_data(self, control_frame: bool = False) -> tuple:409 """410 Receive data with operation code.411 412 Parameters413 ----------414 control_frame: bool415 a boolean flag indicating whether to return control frame416 data, defaults to False417 418 Returns419 -------420 opcode, frame.data: tuple421 tuple of operation code and string(byte array) value.422 """423 opcode, frame = self.recv_data_frame(control_frame)424 return opcode, frame.data425 426 def recv_data_frame(self, control_frame: bool = False) -> tuple:427 """428 Receive data with operation code.429 430 If a valid ping message is received, a pong response is sent.431 432 Parameters433 ----------434 control_frame: bool435 a boolean flag indicating whether to return control frame436 data, defaults to False437 438 Returns439 -------440 frame.opcode, frame: tuple441 tuple of operation code and string(byte array) value.442 """443 while True:444 frame = self.recv_frame()445 if isEnabledForTrace():446 trace(f"++Rcv raw: {repr(frame.format())}")447 trace(f"++Rcv decoded: {frame.__str__()}")448 if not frame:449 # handle error:450 # 'NoneType' object has no attribute 'opcode'451 raise WebSocketProtocolException(f"Not a valid frame {frame}")452 elif frame.opcode in (453 ABNF.OPCODE_TEXT,454 ABNF.OPCODE_BINARY,455 ABNF.OPCODE_CONT,456 ):457 self.cont_frame.validate(frame)458 self.cont_frame.add(frame)459 460 if self.cont_frame.is_fire(frame):461 return self.cont_frame.extract(frame)462 463 elif frame.opcode == ABNF.OPCODE_CLOSE:464 self.send_close()465 return frame.opcode, frame466 elif frame.opcode == ABNF.OPCODE_PING:467 if len(frame.data) < 126:468 self.pong(frame.data)469 else:470 raise WebSocketProtocolException("Ping message is too long")471 if control_frame:472 return frame.opcode, frame473 elif frame.opcode == ABNF.OPCODE_PONG:474 if control_frame:475 return frame.opcode, frame476 477 def recv_frame(self):478 """479 Receive data as frame from server.480 481 Returns482 -------483 self.frame_buffer.recv_frame(): ABNF frame object484 """485 return self.frame_buffer.recv_frame()486 487 def send_close(self, status: int = STATUS_NORMAL, reason: bytes = b""):488 """489 Send close data to the server.490 491 Parameters492 ----------493 status: int494 Status code to send. See STATUS_XXX.495 reason: str or bytes496 The reason to close. This must be string or UTF-8 bytes.497 """498 if status < 0 or status >= ABNF.LENGTH_16:499 raise ValueError("code is invalid range")500 self.connected = False501 self.send(struct.pack("!H", status) + reason, ABNF.OPCODE_CLOSE)502 503 def close(self, status: int = STATUS_NORMAL, reason: bytes = b"", timeout: int = 3):504 """505 Close Websocket object506 507 Parameters508 ----------509 status: int510 Status code to send. See VALID_CLOSE_STATUS in ABNF.511 reason: bytes512 The reason to close in UTF-8.513 timeout: int or float514 Timeout until receive a close frame.515 If None, it will wait forever until receive a close frame.516 """517 if not self.connected:518 return519 if status < 0 or status >= ABNF.LENGTH_16:520 raise ValueError("code is invalid range")521 522 try:523 self.connected = False524 self.send(struct.pack("!H", status) + reason, ABNF.OPCODE_CLOSE)525 if self.sock is None:526 return527 sock_timeout = self.sock.gettimeout()528 self.sock.settimeout(timeout)529 start_time = time.time()530 while timeout is None or time.time() - start_time < timeout:531 try:532 frame = self.recv_frame()533 if frame.opcode != ABNF.OPCODE_CLOSE:534 continue535 if isEnabledForError():536 recv_status = struct.unpack("!H", frame.data[0:2])[0]537 if recv_status >= 3000 and recv_status <= 4999:538 debug(f"close status: {repr(recv_status)}")539 elif recv_status != STATUS_NORMAL:540 error(f"close status: {repr(recv_status)}")541 break542 except (543 WebSocketConnectionClosedException,544 WebSocketTimeoutException,545 struct.error,546 ):547 break548 if self.sock is not None:549 self.sock.settimeout(sock_timeout)550 self.sock.shutdown(socket.SHUT_RDWR)551 except:552 pass553 554 self.shutdown()555 556 def abort(self):557 """558 Low-level asynchronous abort, wakes up other threads that are waiting in recv_*559 """560 if self.connected:561 self.sock.shutdown(socket.SHUT_RDWR)562 563 def shutdown(self):564 """565 close socket, immediately.566 """567 if self.sock:568 self.sock.close()569 self.sock = None570 self.connected = False571 572 def _send(self, data: Union[str, bytes]):573 if self.sock is None:574 raise WebSocketConnectionClosedException("socket is already closed.")575 if self.dispatcher:576 return self.dispatcher.send(self.sock, data)577 return send(self.sock, data)578 579 def _recv(self, bufsize):580 try:581 return recv(self.sock, bufsize)582 except WebSocketConnectionClosedException:583 if self.sock:584 self.sock.close()585 self.sock = None586 self.connected = False587 raise588 589 590def create_connection(url: str, timeout=None, class_=WebSocket, **options):591 """592 Connect to url and return websocket object.593 594 Connect to url and return the WebSocket object.595 Passing optional timeout parameter will set the timeout on the socket.596 If no timeout is supplied,597 the global default timeout setting returned by getdefaulttimeout() is used.598 You can customize using 'options'.599 If you set "header" list object, you can set your own custom header.600 601 >>> conn = create_connection("ws://echo.websocket.events",602 ... header=["User-Agent: MyProgram",603 ... "x-custom: header"])604 605 Parameters606 ----------607 class_: class608 class to instantiate when creating the connection. It has to implement609 settimeout and connect. It's __init__ should be compatible with610 WebSocket.__init__, i.e. accept all of it's kwargs.611 header: list or dict612 custom http header list or dict.613 cookie: str614 Cookie value.615 origin: str616 custom origin url.617 suppress_origin: bool618 suppress outputting origin header.619 host: str620 custom host header string.621 timeout: int or float622 socket timeout time. This value could be either float/integer.623 If set to None, it uses the default_timeout value.624 http_proxy_host: str625 HTTP proxy host name.626 http_proxy_port: str or int627 HTTP proxy port. If not set, set to 80.628 http_no_proxy: list629 Whitelisted host names that don't use the proxy.630 http_proxy_auth: tuple631 HTTP proxy auth information. tuple of username and password. Default is None.632 http_proxy_timeout: int or float633 HTTP proxy timeout, default is 60 sec as per python-socks.634 enable_multithread: bool635 Enable lock for multithread.636 redirect_limit: int637 Number of redirects to follow.638 sockopt: tuple639 Values for socket.setsockopt.640 sockopt must be a tuple and each element is an argument of sock.setsockopt.641 sslopt: dict642 Optional dict object for ssl socket options. See FAQ for details.643 subprotocols: list644 List of available subprotocols. Default is None.645 skip_utf8_validation: bool646 Skip utf8 validation.647 socket: socket648 Pre-initialized stream socket.649 """650 sockopt = options.pop("sockopt", [])651 sslopt = options.pop("sslopt", {})652 fire_cont_frame = options.pop("fire_cont_frame", False)653 enable_multithread = options.pop("enable_multithread", True)654 skip_utf8_validation = options.pop("skip_utf8_validation", False)655 websock = class_(656 sockopt=sockopt,657 sslopt=sslopt,658 fire_cont_frame=fire_cont_frame,659 enable_multithread=enable_multithread,660 skip_utf8_validation=skip_utf8_validation,661 **options,662 )663 websock.settimeout(timeout if timeout is not None else getdefaulttimeout())664 websock.connect(url, **options)665 return websock666 