Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ picows Release History
:depth: 1
:local:

1.20.0 ()
------------------

* ws_connect/ws_create_server logger_name parameter can now accept a logger-like object
* ws_connect/ws_create_server websocket_handshake_timeout param can now accept None to disable handshake timeouts
* Introduce new exceptions: WSInvalidMessageError, WSInvalidStatusError, WSInvalidHeaderError, WSInvalidUpgradeError
* Allow sending close frames only using send_close to simplify logic
* Raise ValueError instead of assert on some invalid user input
* Added rsv2 and rsv3 to WSTransport send methods

1.19.0 (2026-04-24)
------------------

Expand Down
13 changes: 13 additions & 0 deletions docs/source/guides.rst
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,24 @@ Additionally, websocket-specific failures are represented by :any:`WSError`
and its subclasses:

* :any:`WSHandshakeError` for HTTP upgrade negotiation failures (raised by :any:`ws_connect`).
More specific subclasses may be raised:

* :any:`WSInvalidMessageError` for malformed HTTP upgrade responses.
* :any:`WSInvalidStatusError` when the HTTP response status isn't ``101 Switching Protocols``.
* :any:`WSInvalidHeaderError` for invalid handshake headers such as
``Content-Length`` or ``Sec-WebSocket-Accept``.
* :any:`WSInvalidUpgradeError` for invalid ``Upgrade`` / ``Connection`` headers.

Redirect-following failures in :any:`ws_connect` currently still raise the
base :any:`WSHandshakeError`.
* :any:`WSProtocolError` for websocket parser/protocol violations (can be re-raised by :any:`WSTransport.wait_disconnected` on client side).
* :any:`WSInvalidURL` for invalid websocket/proxy URL inputs.

In general, :any:`WSError` is reserved for websocket-specific failures only.

Handshake timeouts are separate and currently raise `asyncio.TimeoutError`,
not :any:`WSError`.

There is also a special exception, `asyncio.CancelledError`, which any coroutine
can raise when it is externally cancelled. Sometimes you need to handle this
exception manually. For example, in a reconnection loop where you want to
Expand Down
18 changes: 17 additions & 1 deletion docs/source/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ Classes
.. autoexception:: WSHandshakeError
:show-inheritance:

.. autoexception:: WSInvalidMessageError
:show-inheritance:

.. autoexception:: WSInvalidStatusError
:show-inheritance:

.. autoexception:: WSInvalidHeaderError
:show-inheritance:

.. autoexception:: WSInvalidUpgradeError
:show-inheritance:

.. autoexception:: WSProtocolError
:show-inheritance:

Expand Down Expand Up @@ -231,7 +243,7 @@ Classes

Opening handshake response.

.. py:method:: send_reuse_external_buffer(WSMsgType msg_type, char* msg_ptr, size_t msg_size, bint fin=True, bint rsv1=False)
.. py:method:: send_reuse_external_buffer(WSMsgType msg_type, char* msg_ptr, size_t msg_size, bint fin=True, bint rsv1=False, bint rsv2=False, bint rsv3=False)

**Available only from Cython.**

Expand All @@ -251,6 +263,10 @@ Classes
:param rsv1: first reserved bit in websocket frame.
Some protocol extensions use it to indicate that payload
is compressed.
:param rsv2: second reserved bit in websocket frame.
Protocol extensions can use this flag.
:param rsv3: third reserved bit in websocket frame.
Protocol extensions can use this flag.

Enums
-----
Expand Down
8 changes: 8 additions & 0 deletions picows/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from .types import (
WSError,
WSHandshakeError,
WSInvalidMessageError,
WSInvalidStatusError,
WSInvalidHeaderError,
WSInvalidUpgradeError,
WSProtocolError,
WSUpgradeRequest,
WSUpgradeResponse,
Expand Down Expand Up @@ -30,6 +34,10 @@
__all__ = [
'WSError',
'WSHandshakeError',
'WSInvalidMessageError',
'WSInvalidStatusError',
'WSInvalidHeaderError',
'WSInvalidUpgradeError',
'WSProtocolError',
'WSUpgradeRequest',
'WSUpgradeResponse',
Expand Down
47 changes: 37 additions & 10 deletions picows/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
from dataclasses import dataclass
from functools import partial
from inspect import isawaitable
from logging import getLogger
from logging import Logger, LoggerAdapter, getLogger
from ssl import SSLContext
from typing import Callable, Optional, Union, Dict, Any, Awaitable, cast
from typing import Callable, Optional, Union, Dict, Any, Awaitable, cast, TYPE_CHECKING

from python_socks.async_.asyncio import Proxy

Expand All @@ -21,6 +21,13 @@
WSServerListenerFactory = Callable[[WSUpgradeRequest], Union[WSListener, WSUpgradeResponseWithListener, None]]
WSSocketFactory = Callable[[WSParsedURL], Union[Optional[socket.socket], Awaitable[Optional[socket.socket]]]]

if TYPE_CHECKING:
_WSLoggerAdapter = LoggerAdapter[Any]
else:
_WSLoggerAdapter = LoggerAdapter

WSLoggerLike = Union[str, Logger, _WSLoggerAdapter, None]

_HAS_AIOFASTNET = False
try:
import aiofastnet
Expand Down Expand Up @@ -61,6 +68,20 @@ def _is_connected(sock: socket.socket) -> bool:
except OSError:
return False


def _resolve_logger(
logger_name: WSLoggerLike,
default_suffix: str,
prefix: str = "picows."
) -> Union[Logger, _WSLoggerAdapter]:
if logger_name is None:
return getLogger(f"{prefix}{default_suffix}")

if isinstance(logger_name, str):
return getLogger(f"{prefix}{logger_name}")

return logger_name

@dataclass
class _ConnectedSocket:
sock: Optional[socket.socket]
Expand Down Expand Up @@ -171,8 +192,8 @@ async def ws_connect(ws_listener_factory: WSListenerFactory, # type: ignore [no-
*,
ssl_context: Optional[SSLContext] = None,
disconnect_on_exception: bool = True,
websocket_handshake_timeout: float = 5,
logger_name: str = "client",
websocket_handshake_timeout: Optional[float] = 5,
logger_name: WSLoggerLike = None,
enable_auto_ping: bool = False,
auto_ping_idle_timeout: float = 10,
auto_ping_reply_timeout: float = 10,
Expand Down Expand Up @@ -205,8 +226,11 @@ async def ws_connect(ws_listener_factory: WSListenerFactory, # type: ignore [no-
:param websocket_handshake_timeout:
is the time in seconds to wait for the websocket client to receive
websocket handshake response before aborting the connection.
Set to ``None`` to disable the timeout.
:param logger_name:
picows will use `picows.<logger_name>` logger to do all the logging.
Logger name suffix or logger-like object used for logging.
If a string is provided, picows will use `picows.<logger_name>`.
If ``None`` is provided, picows will use ``picows.client``.
:param enable_auto_ping:
Enable detection of a stale connection by periodically pinging remote peer.

Expand Down Expand Up @@ -273,7 +297,7 @@ async def ws_connect(ws_listener_factory: WSListenerFactory, # type: ignore [no-
# May sure people who are passing old argument are not going to get an exception
kwargs.pop('zero_copy_unsafe_ssl_write', None)

logger = getLogger(f"picows.{logger_name}")
logger = _resolve_logger(logger_name, "client")
parsed_url = parse_url(url)
parsed_proxy_url = parse_url(proxy, False) if proxy is not None else None
loop = asyncio.get_running_loop()
Expand Down Expand Up @@ -341,8 +365,8 @@ async def ws_create_server(ws_listener_factory: WSServerListenerFactory,
port=None,
*,
disconnect_on_exception: bool = True,
websocket_handshake_timeout=5,
logger_name: str = "server",
websocket_handshake_timeout: Optional[float] = 5,
logger_name: WSLoggerLike = None,
enable_auto_ping: bool = False,
auto_ping_idle_timeout: float = 20,
auto_ping_reply_timeout: float = 20,
Expand Down Expand Up @@ -388,8 +412,11 @@ async def ws_create_server(ws_listener_factory: WSServerListenerFactory,
thrown by WSListener.on_ws_frame callback
:param websocket_handshake_timeout:
is the time in seconds to wait for the websocket server to receive websocket handshake request before aborting the connection.
Set to ``None`` to disable the timeout.
:param logger_name:
picows will use `picows.<logger_name>` logger to do all the logging.
Logger name suffix or logger-like object used for logging.
If a string is provided, picows will use `picows.<logger_name>`.
If ``None`` is provided, picows will use ``picows.server``.
:param enable_auto_ping:
Enable detection of a stale connection by periodically pinging remote peer.

Expand Down Expand Up @@ -444,7 +471,7 @@ def ws_protocol_factory() -> WSProtocol:
None, # ws_path
False, # is_client_side
ws_listener_factory,
getLogger(f"picows.{logger_name}"),
_resolve_logger(logger_name, "server"),
disconnect_on_exception,
websocket_handshake_timeout,
enable_auto_ping, auto_ping_idle_timeout, auto_ping_reply_timeout,
Expand Down
35 changes: 25 additions & 10 deletions picows/picows.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ cpdef enum WSAutoPingStrategy:
PING_PERIODICALLY = 2


cdef class WSCloseInfo:
cdef:
readonly WSCloseCode code
readonly str reason


cdef class WSCloseHandshake:
cdef:
readonly WSCloseInfo recv
readonly WSCloseInfo sent
readonly bint recv_then_sent


cdef class MemoryBuffer:
cdef:
Py_ssize_t size
Expand Down Expand Up @@ -70,15 +83,17 @@ cdef class WSFrame:

cpdef WSCloseCode get_close_code(self)
cpdef bytes get_close_message(self)
cpdef str get_close_reason(self)


cdef class WSTransport:
cdef:
object __weakref__

readonly object underlying_transport #: asyncio.Transport
readonly object request #: WSUpgradeRequest
readonly object response #: WSUpgradeResponse
readonly object underlying_transport #: asyncio.Transport
readonly object request #: WSUpgradeRequest
readonly object response #: WSUpgradeResponse
readonly WSCloseHandshake close_handshake #: Optional[WSCloseHandshake]
readonly bint is_client_side
readonly bint is_secure
readonly bint is_close_frame_sent
Expand All @@ -88,6 +103,7 @@ cdef class WSTransport:
object listener_proxy
object disconnected_future #: asyncio.Future


object _loop
object _logger #: Logger
MemoryBuffer _write_buffer
Expand All @@ -97,9 +113,9 @@ cdef class WSTransport:
bint _is_aiofn_transport
bint _log_debug_enabled

cdef inline send_reuse_external_buffer(self, WSMsgType msg_type, char* msg_ptr, Py_ssize_t msg_size, bint fin=*, bint rsv1=*)
cpdef send(self, WSMsgType msg_type, message, bint fin=*, bint rsv1=*)
cpdef send_reuse_external_bytearray(self, WSMsgType msg_type, bytearray buffer, Py_ssize_t msg_offset, bint fin=*, bint rsv1=*)
cdef inline send_reuse_external_buffer(self, WSMsgType msg_type, char* msg_ptr, Py_ssize_t msg_size, bint fin=*, bint rsv1=*, bint rsv2=*, bint rsv3=*)
cpdef send(self, WSMsgType msg_type, message, bint fin=*, bint rsv1=*, bint rsv2=*, bint rsv3=*)
cpdef send_reuse_external_bytearray(self, WSMsgType msg_type, bytearray buffer, Py_ssize_t msg_offset, bint fin=*, bint rsv1=*, bint rsv2=*, bint rsv3=*)
cpdef send_ping(self, message=*)
cpdef send_pong(self, message=*)
cpdef send_close(self, WSCloseCode close_code=*, close_message=*)
Expand All @@ -110,9 +126,9 @@ cdef class WSTransport:
cdef inline Py_ssize_t _get_header_size(self, Py_ssize_t msg_size) noexcept
cdef inline _send_buffer(self, WSMsgType msg_type,
char* msg_ptr, Py_ssize_t msg_size,
bint fin, bint rsv1)
cdef inline _send(self, WSMsgType msg_type, message, bint fin, bint rsv1)
cdef inline uint32_t _prepare_header(self, uint8_t* header_ptr, WSMsgType msg_type, Py_ssize_t msg_size, bint fin, bint rsv1) noexcept
bint fin, bint rsv1, bint rsv2, bint rsv3)
cdef inline _send(self, WSMsgType msg_type, message, bint fin, bint rsv1, bint rsv2, bint rsv3)
cdef inline uint32_t _prepare_header(self, uint8_t* header_ptr, WSMsgType msg_type, Py_ssize_t msg_size, bint fin, bint rsv1, bint rsv2, bint rsv3) noexcept
cdef inline _send_http_handshake(self, bytes ws_path, bytes host_port, bytes websocket_key_b64, object extra_headers)
cdef inline _send_http_handshake_response(self, response, bytes accept_val)
cdef inline _fast_write(self, char* ptr, Py_ssize_t sz)
Expand All @@ -130,4 +146,3 @@ cdef class WSListener:

cpdef pause_writing(self)
cpdef resume_writing(self)

37 changes: 31 additions & 6 deletions picows/picows.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ class WSAutoPingStrategy(Enum):
PING_PERIODICALLY = 2


class WSCloseInfo:
code: WSCloseCode
reason: str


class WSCloseHandshake:
recv: Optional[WSCloseInfo]
sent: Optional[WSCloseInfo]
recv_then_sent: bool


class WSFrame:
@property
def tail_size(self) -> int: ...
Expand All @@ -49,6 +60,12 @@ class WSFrame:
@property
def rsv1(self) -> bool: ...

@property
def rsv2(self) -> bool: ...

@property
def rsv3(self) -> bool: ...

@property
def last_in_buffer(self) -> bool: ...

Expand All @@ -58,6 +75,7 @@ class WSFrame:
def get_payload_as_memoryview(self) -> memoryview: ...
def get_close_code(self) -> WSCloseCode: ...
def get_close_message(self) -> bytes: ...
def get_close_reason(self) -> str: ...
def __str__(self) -> str: ...


Expand All @@ -66,34 +84,41 @@ class WSTransport:
def underlying_transport(self) -> asyncio.Transport: ...

@property
def is_client_side(self) -> bool: ...
def request(self) -> WSUpgradeRequest: ...

@property
def is_secure(self) -> bool: ...
def response(self) -> WSUpgradeResponse: ...

@property
def is_close_frame_sent(self) -> bool: ...
def close_handshake(self) -> WSCloseHandshake: ...

@property
def request(self) -> WSUpgradeRequest: ...
def is_client_side(self) -> bool: ...

@property
def response(self) -> WSUpgradeResponse: ...
def is_secure(self) -> bool: ...

@property
def is_close_frame_sent(self) -> bool: ...

def send(
self,
msg_type: WSMsgType,
message: Optional[WSBuffer],
fin: bool = True,
rsv1: bool = False,
rsv2: bool = False,
rsv3: bool = False,
) -> None: ...
def send_reuse_external_bytearray(
self,
msg_type: WSMsgType,
buffer: bytearray,
msg_offset: int,
fin: bool = True,
rsv1: bool = False
rsv1: bool = False,
rsv2: bool = False,
rsv3: bool = False,
) -> None: ...
def send_ping(self, message: Optional[WSBuffer]=None) -> None: ...
def send_pong(self, message: Optional[WSBuffer]=None) -> None: ...
Expand Down
Loading