Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/master' into aux/1.1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
droserasprout committed Nov 29, 2024
2 parents a7cb45a + 2637486 commit 0897716
Show file tree
Hide file tree
Showing 11 changed files with 1,185 additions and 854 deletions.
18 changes: 11 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
.ONESHELL:
.PHONY: $(MAKECMDGOALS)
SRC = src tests example.py example_with_token.py
MAKEFLAGS += --no-print-directory
##
## 🚧 pysignalr developer tools
##

##
SOURCE = src tests example.py example_with_token.py

help: ## Show this help (default)
@grep -F -h "##" $(MAKEFILE_LIST) | grep -F -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//'

install: ## Install dependencies
poetry sync

update: ## Update dependencies
poetry update

all: ## Run a whole CI pipeline: formatters, linters, tests
make lint test

Expand All @@ -22,13 +26,13 @@ test: ## Run test suite
##

black: ## Format with black
black $(SRC)
black $(SOURCE)

ruff: ## Lint with ruff
ruff check --fix --unsafe-fixes $(SRC)
ruff check --fix --unsafe-fixes $(SOURCE)

mypy: ## Lint with mypy
mypy --strict $(SRC)
mypy --strict $(SOURCE)

cover: ## Print coverage for the current branch
diff-cover --compare-branch origin/master coverage.xml
Expand Down
1,901 changes: 1,097 additions & 804 deletions poetry.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ packages = [

[tool.poetry.dependencies]
python = ">=3.8,<4"
websockets = "*"
websockets = ">=12.0,<14"
aiohttp = "*"
msgpack = "*"
orjson = "*"
Expand All @@ -61,15 +61,15 @@ skip-string-normalization = true

[tool.ruff]
line-length = 120
target-version = "py38"
target-version = "py312"

[tool.ruff.lint]
extend-select = ["B", "C4", "FA", "G", "I", "PTH", "Q", "RUF", "TCH", "UP"]
flake8-quotes = { inline-quotes = "single", multiline-quotes = "double" }
isort = { force-single-line = true, known-first-party = ["pysignalr"] }

[tool.mypy]
python_version = "3.8"
python_version = "3.12"
strict = true

[tool.pytest.ini_options]
Expand Down
30 changes: 9 additions & 21 deletions src/pysignalr/__init__.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,15 @@
import importlib.metadata

# Get the version of the 'pysignalr' package
__version__ = importlib.metadata.version('pysignalr')

import asyncio
import importlib.metadata
import random
from http import HTTPStatus
from typing import AsyncIterator
from collections.abc import AsyncIterator

import websockets.legacy.client
from websockets.exceptions import InvalidStatusCode

from pysignalr.exceptions import NegotiationFailure

class NegotiationTimeout(Exception):
"""
Exception raised when the connection URL generated during negotiation is no longer valid.
"""

pass
# Get the version of the 'pysignalr' package
__version__ = importlib.metadata.version('pysignalr')


async def __aiter__(
Expand All @@ -36,20 +28,16 @@ async def __aiter__(
websockets.legacy.client.WebSocketClientProtocol: The WebSocket protocol.
Raises:
NegotiationTimeout: If the connection URL is no longer valid during negotiation.
NegotiationFailure: If the connection URL is no longer valid during negotiation.
"""
backoff_delay = self.BACKOFF_MIN
while True:
try:
async with self as protocol:
yield protocol

# Handle expired connection URLs by raising a NegotiationTimeout exception.
except InvalidStatusCode as e:
if e.status_code == HTTPStatus.NOT_FOUND:
raise NegotiationTimeout from e
except asyncio.TimeoutError as e:
raise NegotiationTimeout from e
# Handle expired connection URLs by raising a NegotiationFailure exception.
except (TimeoutError, InvalidStatusCode) as e:
raise NegotiationFailure from e

except Exception:
# Add a random initial delay between 0 and 5 seconds.
Expand Down
19 changes: 15 additions & 4 deletions src/pysignalr/client.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
from __future__ import annotations

import uuid
import ssl
from collections import defaultdict
from collections.abc import AsyncIterator
from collections.abc import Awaitable
from collections.abc import Callable
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING
from typing import Any
from typing import AsyncIterator
from typing import Awaitable
from typing import Callable

from pysignalr.exceptions import ServerError
from pysignalr.messages import CancelInvocationMessage
Expand All @@ -26,12 +25,18 @@
from pysignalr.transport.websocket import DEFAULT_CONNECTION_TIMEOUT
from pysignalr.transport.websocket import DEFAULT_MAX_SIZE
from pysignalr.transport.websocket import DEFAULT_PING_INTERVAL
from pysignalr.transport.websocket import DEFAULT_RETRY_COUNT
from pysignalr.transport.websocket import DEFAULT_RETRY_MULTIPLIER
from pysignalr.transport.websocket import DEFAULT_RETRY_SLEEP
from pysignalr.transport.websocket import WebsocketTransport

if TYPE_CHECKING:
import ssl

from pysignalr.protocol.abstract import Protocol
from pysignalr.transport.abstract import Transport


EmptyCallback = Callable[[], Awaitable[None]]
AnyCallback = Callable[[Any], Awaitable[None]]
MessageCallback = Callable[[Message], Awaitable[None]]
Expand Down Expand Up @@ -102,6 +107,9 @@ def __init__(
ping_interval: int = DEFAULT_PING_INTERVAL,
connection_timeout: int = DEFAULT_CONNECTION_TIMEOUT,
max_size: int | None = DEFAULT_MAX_SIZE,
retry_sleep: float = DEFAULT_RETRY_SLEEP,
retry_multiplier: float = DEFAULT_RETRY_MULTIPLIER,
retry_count: int = DEFAULT_RETRY_COUNT,
access_token_factory: Callable[[], str] | None = None,
ssl: ssl.SSLContext | None = None,
) -> None:
Expand All @@ -123,6 +131,9 @@ def __init__(
callback=self._on_message,
headers=self._headers,
ping_interval=ping_interval,
retry_sleep=retry_sleep,
retry_multiplier=retry_multiplier,
retry_count=retry_count,
connection_timeout=connection_timeout,
max_size=max_size,
access_token_factory=access_token_factory,
Expand Down
9 changes: 9 additions & 0 deletions src/pysignalr/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,12 @@ class ServerError(HubError):
"""

message: str | None


@dataclass(frozen=True)
class NegotiationFailure(HubError):
"""
Exception raised when the protocol negotiation fails.
"""

pass
5 changes: 4 additions & 1 deletion src/pysignalr/protocol/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

from abc import ABC
from abc import abstractmethod
from typing import Iterable
from typing import TYPE_CHECKING

from pysignalr.messages import HandshakeRequestMessage
from pysignalr.messages import HandshakeResponseMessage
from pysignalr.messages import Message

if TYPE_CHECKING:
from collections.abc import Iterable


class Protocol(ABC):
"""
Expand Down
5 changes: 4 additions & 1 deletion src/pysignalr/protocol/json.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from __future__ import annotations

from json import JSONEncoder
from typing import TYPE_CHECKING
from typing import Any
from typing import Iterable

import orjson

Expand All @@ -21,6 +21,9 @@
from pysignalr.messages import StreamItemMessage # 2
from pysignalr.protocol.abstract import Protocol

if TYPE_CHECKING:
from collections.abc import Iterable


class MessageEncoder(JSONEncoder):
"""
Expand Down
7 changes: 5 additions & 2 deletions src/pysignalr/protocol/messagepack.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

# TODO: Refactor this module
from collections import deque
from typing import TYPE_CHECKING
from typing import Any
from typing import Iterable
from typing import Sequence
from typing import cast

import msgpack # type: ignore[import-untyped]
Expand All @@ -24,6 +23,10 @@
from pysignalr.messages import StreamItemMessage
from pysignalr.protocol.abstract import Protocol

if TYPE_CHECKING:
from collections.abc import Iterable
from collections.abc import Sequence

_attribute_priority = (
# NOTE: Python limitation, left as is
'type_',
Expand Down
35 changes: 26 additions & 9 deletions src/pysignalr/transport/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@

import asyncio
import logging
import ssl
from contextlib import suppress
from http import HTTPStatus
from typing import TYPE_CHECKING
from typing import Awaitable
from typing import Callable

from aiohttp import ClientSession
from aiohttp import ClientTimeout
Expand All @@ -18,7 +14,6 @@
from websockets.protocol import State

import pysignalr.exceptions as exceptions
from pysignalr import NegotiationTimeout
from pysignalr.messages import CompletionMessage
from pysignalr.messages import Message
from pysignalr.messages import PingMessage
Expand All @@ -29,12 +24,20 @@
from pysignalr.utils import replace_scheme

if TYPE_CHECKING:
import ssl
from collections.abc import Awaitable
from collections.abc import Callable

from pysignalr.protocol.abstract import Protocol

DEFAULT_MAX_SIZE = 2**20 # 1 MB
DEFAULT_PING_INTERVAL = 10
DEFAULT_CONNECTION_TIMEOUT = 10

DEFAULT_RETRY_SLEEP = 1
DEFAULT_RETRY_MULTIPLIER = 1.1
DEFAULT_RETRY_COUNT = 10

_logger = logging.getLogger('pysignalr.transport')


Expand Down Expand Up @@ -64,6 +67,9 @@ def __init__(
skip_negotiation: bool = False,
ping_interval: int = DEFAULT_PING_INTERVAL,
connection_timeout: int = DEFAULT_CONNECTION_TIMEOUT,
retry_sleep: float = DEFAULT_RETRY_SLEEP,
retry_multiplier: float = DEFAULT_RETRY_MULTIPLIER,
retry_count: int = DEFAULT_RETRY_COUNT,
max_size: int | None = DEFAULT_MAX_SIZE,
access_token_factory: Callable[[], str] | None = None,
ssl: ssl.SSLContext | None = None,
Expand Down Expand Up @@ -92,6 +98,9 @@ def __init__(
self._connection_timeout = connection_timeout
self._max_size = max_size
self._access_token_factory = access_token_factory
self._retry_sleep = retry_sleep
self._retry_multiplier = retry_multiplier
self._retry_count = retry_count
self._ssl = ssl

self._state = ConnectionState.disconnected
Expand Down Expand Up @@ -132,9 +141,17 @@ async def run(self) -> None:
Runs the WebSocket transport, managing the connection lifecycle.
"""
while True:
with suppress(NegotiationTimeout):
try:
await self._loop()
await self._set_state(ConnectionState.disconnected)
except exceptions.NegotiationFailure as e:
await self._set_state(ConnectionState.disconnected)
self._retry_count -= 1
if self._retry_count <= 0:
raise e
self._retry_sleep *= self._retry_multiplier
await asyncio.sleep(self._retry_sleep)
else:
await self._set_state(ConnectionState.disconnected)

async def send(self, message: Message) -> None:
"""
Expand All @@ -156,7 +173,7 @@ async def _loop(self) -> None:
try:
await self._negotiate()
except ServerConnectionError as e:
raise NegotiationTimeout from e
raise exceptions.NegotiationFailure from e

# Since websockets interprets the presence of the ssl option as something different than providing None,
# the call needs to be made with or without ssl option to work properly
Expand Down Expand Up @@ -245,7 +262,7 @@ async def _get_connection(self) -> WebSocketClientProtocol:
"""
try:
await asyncio.wait_for(self._connected.wait(), self._connection_timeout)
except asyncio.TimeoutError as e:
except TimeoutError as e:
raise RuntimeError('The socket was never run') from e
if not self._ws or self._ws.state != State.OPEN:
raise RuntimeError('Connection is closed')
Expand Down
4 changes: 2 additions & 2 deletions src/pysignalr/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

http_schemas = ('http', 'https')
websocket_schemas = ('ws', 'wss')
http_to_ws = {k: v for k, v in zip(http_schemas, websocket_schemas)} # noqa: C416
ws_to_http = {k: v for k, v in zip(websocket_schemas, http_schemas)} # noqa: C416
http_to_ws = {k: v for k, v in zip(http_schemas, websocket_schemas, strict=False)} # noqa: C416
ws_to_http = {k: v for k, v in zip(websocket_schemas, http_schemas, strict=False)} # noqa: C416


def replace_scheme(url: str, ws: bool) -> str:
Expand Down

0 comments on commit 0897716

Please sign in to comment.