From cd6def807d0f6cb6c83ca445752db1cd47167b45 Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Sat, 28 Mar 2026 10:30:02 +0800 Subject: [PATCH 01/21] :bug: Fix websocket connection timeout handling in Mixin class --- nonebot/drivers/websockets.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nonebot/drivers/websockets.py b/nonebot/drivers/websockets.py index 3e6aa07ba6d6..3eb1d3506d78 100644 --- a/nonebot/drivers/websockets.py +++ b/nonebot/drivers/websockets.py @@ -72,14 +72,17 @@ def type(self) -> str: async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: if isinstance(setup.timeout, Timeout): timeout = setup.timeout.total or setup.timeout.connect or setup.timeout.read + close_timeout = setup.timeout.total else: timeout = setup.timeout + close_timeout = setup.timeout or 10.0 connection = connect( str(setup.url), additional_headers={**setup.headers, **setup.cookies.as_header(setup)}, proxy=setup.proxy if setup.proxy is not None else True, open_timeout=timeout, + close_timeout=close_timeout, ) async with connection as ws: yield WebSocket(request=setup, websocket=ws) From 75985287b109295a8afb1585e2f06a7cd0e07770 Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Sat, 28 Mar 2026 10:33:07 +0800 Subject: [PATCH 02/21] :bug: Improve default close timeout handling in Mixin class --- nonebot/drivers/websockets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nonebot/drivers/websockets.py b/nonebot/drivers/websockets.py index 3eb1d3506d78..ab90dbf57b32 100644 --- a/nonebot/drivers/websockets.py +++ b/nonebot/drivers/websockets.py @@ -72,7 +72,7 @@ def type(self) -> str: async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: if isinstance(setup.timeout, Timeout): timeout = setup.timeout.total or setup.timeout.connect or setup.timeout.read - close_timeout = setup.timeout.total + close_timeout = setup.timeout.total or 10.0 else: timeout = setup.timeout close_timeout = setup.timeout or 10.0 From a34f0b77e40d8d94ba9e38131a121c2152ee3192 Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Sat, 28 Mar 2026 10:38:10 +0800 Subject: [PATCH 03/21] :bug: Refactor timeout handling in Mixin classes to ensure proper default values for close timeout --- nonebot/drivers/aiohttp.py | 2 +- nonebot/drivers/websockets.py | 4 ++-- nonebot/internal/driver/model.py | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/nonebot/drivers/aiohttp.py b/nonebot/drivers/aiohttp.py index cb9aa810cd79..dabf7e1c709b 100644 --- a/nonebot/drivers/aiohttp.py +++ b/nonebot/drivers/aiohttp.py @@ -273,7 +273,7 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: if isinstance(setup.timeout, Timeout): timeout = aiohttp.ClientWSTimeout( ws_receive=setup.timeout.read, # type: ignore - ws_close=setup.timeout.total, # type: ignore + ws_close=setup.timeout.total or setup.timeout.close, # type: ignore ) else: timeout = aiohttp.ClientWSTimeout(ws_close=setup.timeout or 10.0) # type: ignore diff --git a/nonebot/drivers/websockets.py b/nonebot/drivers/websockets.py index ab90dbf57b32..b4cb35106300 100644 --- a/nonebot/drivers/websockets.py +++ b/nonebot/drivers/websockets.py @@ -72,10 +72,10 @@ def type(self) -> str: async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: if isinstance(setup.timeout, Timeout): timeout = setup.timeout.total or setup.timeout.connect or setup.timeout.read - close_timeout = setup.timeout.total or 10.0 + close_timeout = setup.timeout.close or 10.0 else: timeout = setup.timeout - close_timeout = setup.timeout or 10.0 + close_timeout = 10.0 connection = connect( str(setup.url), diff --git a/nonebot/internal/driver/model.py b/nonebot/internal/driver/model.py index 169d589d129d..728847630a6f 100644 --- a/nonebot/internal/driver/model.py +++ b/nonebot/internal/driver/model.py @@ -17,6 +17,7 @@ class Timeout: total: float | None = None connect: float | None = None read: float | None = None + close: float | None = 10.0 RawURL: TypeAlias = tuple[bytes, bytes, int | None, bytes] From c669a187af352024e36b0e0f9f20255d7da0c56c Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Sat, 28 Mar 2026 10:43:10 +0800 Subject: [PATCH 04/21] :bug: Set close timeout to None for improved timeout handling in Timeout class --- nonebot/drivers/websockets.py | 4 ++-- nonebot/internal/driver/model.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nonebot/drivers/websockets.py b/nonebot/drivers/websockets.py index b4cb35106300..2a1ed00191d1 100644 --- a/nonebot/drivers/websockets.py +++ b/nonebot/drivers/websockets.py @@ -72,10 +72,10 @@ def type(self) -> str: async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: if isinstance(setup.timeout, Timeout): timeout = setup.timeout.total or setup.timeout.connect or setup.timeout.read - close_timeout = setup.timeout.close or 10.0 + close_timeout = setup.timeout.close else: timeout = setup.timeout - close_timeout = 10.0 + close_timeout = setup.timeout or 10.0 connection = connect( str(setup.url), diff --git a/nonebot/internal/driver/model.py b/nonebot/internal/driver/model.py index 728847630a6f..ba86431347af 100644 --- a/nonebot/internal/driver/model.py +++ b/nonebot/internal/driver/model.py @@ -17,7 +17,7 @@ class Timeout: total: float | None = None connect: float | None = None read: float | None = None - close: float | None = 10.0 + close: float | None = None RawURL: TypeAlias = tuple[bytes, bytes, int | None, bytes] From d9825373b78624b29754319084bbcf3247098e29 Mon Sep 17 00:00:00 2001 From: StarHeart Date: Sat, 28 Mar 2026 11:53:12 +0800 Subject: [PATCH 05/21] Update nonebot/drivers/aiohttp.py Co-authored-by: Ju4tCode <42488585+yanyongyu@users.noreply.github.com> --- nonebot/drivers/aiohttp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nonebot/drivers/aiohttp.py b/nonebot/drivers/aiohttp.py index dabf7e1c709b..06d0f967ed8a 100644 --- a/nonebot/drivers/aiohttp.py +++ b/nonebot/drivers/aiohttp.py @@ -273,7 +273,7 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: if isinstance(setup.timeout, Timeout): timeout = aiohttp.ClientWSTimeout( ws_receive=setup.timeout.read, # type: ignore - ws_close=setup.timeout.total or setup.timeout.close, # type: ignore + ws_close=setup.timeout.close or setup.timeout.total, # type: ignore ) else: timeout = aiohttp.ClientWSTimeout(ws_close=setup.timeout or 10.0) # type: ignore From 18fdd3c12447d9ad1c91230eee54526ec9dfaa4a Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Sat, 28 Mar 2026 15:26:16 +0800 Subject: [PATCH 06/21] :recycle: timeout --- nonebot/drivers/__init__.py | 2 ++ nonebot/drivers/aiohttp.py | 42 ++++++++++++++++++----------- nonebot/drivers/websockets.py | 27 ++++++++++++++----- nonebot/internal/driver/__init__.py | 2 ++ nonebot/internal/driver/model.py | 30 ++++++++++++++++++--- 5 files changed, 76 insertions(+), 27 deletions(-) diff --git a/nonebot/drivers/__init__.py b/nonebot/drivers/__init__.py index c7e6e82dd016..8e91d609ce91 100644 --- a/nonebot/drivers/__init__.py +++ b/nonebot/drivers/__init__.py @@ -9,6 +9,7 @@ description: nonebot.drivers 模块 """ +from nonebot.internal.driver import UNSET as UNSET from nonebot.internal.driver import URL as URL from nonebot.internal.driver import ASGIMixin as ASGIMixin from nonebot.internal.driver import Cookies as Cookies @@ -25,6 +26,7 @@ from nonebot.internal.driver import ReverseDriver as ReverseDriver from nonebot.internal.driver import ReverseMixin as ReverseMixin from nonebot.internal.driver import Timeout as Timeout +from nonebot.internal.driver import Unset as Unset from nonebot.internal.driver import WebSocket as WebSocket from nonebot.internal.driver import WebSocketClientMixin as WebSocketClientMixin from nonebot.internal.driver import WebSocketServerSetup as WebSocketServerSetup diff --git a/nonebot/drivers/aiohttp.py b/nonebot/drivers/aiohttp.py index 06d0f967ed8a..222a45e9937e 100644 --- a/nonebot/drivers/aiohttp.py +++ b/nonebot/drivers/aiohttp.py @@ -19,7 +19,7 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from typing_extensions import override from multidict import CIMultiDict @@ -44,6 +44,7 @@ QueryTypes, Timeout, TimeoutTypes, + Unset, ) try: @@ -86,11 +87,14 @@ def __init__( raise RuntimeError(f"Unsupported HTTP version: {version}") if isinstance(timeout, Timeout): - self._timeout = aiohttp.ClientTimeout( - total=timeout.total, - connect=timeout.connect, - sock_read=timeout.read, - ) + timeout_kwargs: dict[str, Any] = {} + if not isinstance(timeout.total, Unset): + timeout_kwargs["total"] = timeout.total + if not isinstance(timeout.connect, Unset): + timeout_kwargs["connect"] = timeout.connect + if not isinstance(timeout.read, Unset): + timeout_kwargs["sock_read"] = timeout.read + self._timeout = aiohttp.ClientTimeout(**timeout_kwargs) else: self._timeout = aiohttp.ClientTimeout(timeout) @@ -122,11 +126,14 @@ async def request(self, setup: Request) -> Response: ) if isinstance(setup.timeout, Timeout): - timeout = aiohttp.ClientTimeout( - total=setup.timeout.total, - connect=setup.timeout.connect, - sock_read=setup.timeout.read, - ) + timeout_kwargs: dict[str, Any] = {} + if not isinstance(setup.timeout.total, Unset): + timeout_kwargs["total"] = setup.timeout.total + if not isinstance(setup.timeout.connect, Unset): + timeout_kwargs["connect"] = setup.timeout.connect + if not isinstance(setup.timeout.read, Unset): + timeout_kwargs["sock_read"] = setup.timeout.read + timeout = aiohttp.ClientTimeout(**timeout_kwargs) else: timeout = aiohttp.ClientTimeout(setup.timeout) @@ -172,11 +179,14 @@ async def stream_request( ) if isinstance(setup.timeout, Timeout): - timeout = aiohttp.ClientTimeout( - total=setup.timeout.total, - connect=setup.timeout.connect, - sock_read=setup.timeout.read, - ) + timeout_kwargs: dict[str, Any] = {} + if not isinstance(setup.timeout.total, Unset): + timeout_kwargs["total"] = setup.timeout.total + if not isinstance(setup.timeout.connect, Unset): + timeout_kwargs["connect"] = setup.timeout.connect + if not isinstance(setup.timeout.read, Unset): + timeout_kwargs["sock_read"] = setup.timeout.read + timeout = aiohttp.ClientTimeout(**timeout_kwargs) else: timeout = aiohttp.ClientTimeout(setup.timeout) diff --git a/nonebot/drivers/websockets.py b/nonebot/drivers/websockets.py index 2a1ed00191d1..36e89279118b 100644 --- a/nonebot/drivers/websockets.py +++ b/nonebot/drivers/websockets.py @@ -25,7 +25,13 @@ from typing import TYPE_CHECKING, Any, TypeVar from typing_extensions import ParamSpec, override -from nonebot.drivers import Request, Timeout, WebSocketClientMixin, combine_driver +from nonebot.drivers import ( + Request, + Timeout, + Unset, + WebSocketClientMixin, + combine_driver, +) from nonebot.drivers import WebSocket as BaseWebSocket from nonebot.drivers.none import Driver as NoneDriver from nonebot.exception import WebSocketClosed @@ -71,18 +77,25 @@ def type(self) -> str: @asynccontextmanager async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: if isinstance(setup.timeout, Timeout): - timeout = setup.timeout.total or setup.timeout.connect or setup.timeout.read - close_timeout = setup.timeout.close + timeout_kwargs: dict[str, Any] = {} + open_timeout = ( + setup.timeout.total or setup.timeout.connect or setup.timeout.read + ) + if not isinstance(open_timeout, Unset): + timeout_kwargs["open_timeout"] = open_timeout + if not isinstance(setup.timeout.close, Unset): + timeout_kwargs["close_timeout"] = setup.timeout.close else: - timeout = setup.timeout - close_timeout = setup.timeout or 10.0 + timeout_kwargs = { + "open_timeout": setup.timeout, + "close_timeout": setup.timeout or 10.0, + } connection = connect( str(setup.url), additional_headers={**setup.headers, **setup.cookies.as_header(setup)}, proxy=setup.proxy if setup.proxy is not None else True, - open_timeout=timeout, - close_timeout=close_timeout, + **timeout_kwargs, ) async with connection as ws: yield WebSocket(request=setup, websocket=ws) diff --git a/nonebot/internal/driver/__init__.py b/nonebot/internal/driver/__init__.py index e4b3f042c3f6..168e6af4b18c 100644 --- a/nonebot/internal/driver/__init__.py +++ b/nonebot/internal/driver/__init__.py @@ -9,6 +9,7 @@ from .abstract import ReverseMixin as ReverseMixin from .abstract import WebSocketClientMixin as WebSocketClientMixin from .combine import combine_driver as combine_driver +from .model import UNSET as UNSET from .model import URL as URL from .model import ContentTypes as ContentTypes from .model import Cookies as Cookies @@ -29,5 +30,6 @@ from .model import SimpleQuery as SimpleQuery from .model import Timeout as Timeout from .model import TimeoutTypes as TimeoutTypes +from .model import Unset as Unset from .model import WebSocket as WebSocket from .model import WebSocketServerSetup as WebSocketServerSetup diff --git a/nonebot/internal/driver/model.py b/nonebot/internal/driver/model.py index ba86431347af..d4b10ebab43d 100644 --- a/nonebot/internal/driver/model.py +++ b/nonebot/internal/driver/model.py @@ -4,20 +4,42 @@ from enum import Enum from http.cookiejar import Cookie, CookieJar from typing import IO, Any, TypeAlias +from typing_extensions import Self import urllib.request from multidict import CIMultiDict from yarl import URL as URL +class Unset: + """Sentinel for unset timeout fields.""" + + __slots__ = () + _instance: Self | None = None + + def __new__(cls) -> Self: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "UNSET" + + def __bool__(self) -> bool: + return False + + +UNSET = Unset() + + @dataclass class Timeout: """Request 超时配置。""" - total: float | None = None - connect: float | None = None - read: float | None = None - close: float | None = None + total: float | None | Unset = UNSET + connect: float | None | Unset = UNSET + read: float | None | Unset = UNSET + close: float | None | Unset = UNSET RawURL: TypeAlias = tuple[bytes, bytes, int | None, bytes] From 415b1f492a1ba76bd15ae0835011720f3115ca31 Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Sat, 28 Mar 2026 15:32:24 +0800 Subject: [PATCH 07/21] :recycle: timeout handling --- nonebot/drivers/aiohttp.py | 11 ++++++--- nonebot/drivers/httpx.py | 42 ++++++++++++++++++++------------ nonebot/internal/driver/model.py | 2 +- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/nonebot/drivers/aiohttp.py b/nonebot/drivers/aiohttp.py index 222a45e9937e..979ce16a228e 100644 --- a/nonebot/drivers/aiohttp.py +++ b/nonebot/drivers/aiohttp.py @@ -281,10 +281,13 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: raise RuntimeError(f"Unsupported HTTP version: {setup.version}") if isinstance(setup.timeout, Timeout): - timeout = aiohttp.ClientWSTimeout( - ws_receive=setup.timeout.read, # type: ignore - ws_close=setup.timeout.close or setup.timeout.total, # type: ignore - ) + timeout_kwargs: dict[str, Any] = {} + if not isinstance(setup.timeout.read, Unset): + timeout_kwargs["ws_receive"] = setup.timeout.read + ws_close = setup.timeout.close or setup.timeout.total + if not isinstance(ws_close, Unset): + timeout_kwargs["ws_close"] = ws_close + timeout = aiohttp.ClientWSTimeout(**timeout_kwargs) # type: ignore else: timeout = aiohttp.ClientWSTimeout(ws_close=setup.timeout or 10.0) # type: ignore diff --git a/nonebot/drivers/httpx.py b/nonebot/drivers/httpx.py index 70bec59562f9..58b8516f642a 100644 --- a/nonebot/drivers/httpx.py +++ b/nonebot/drivers/httpx.py @@ -18,7 +18,7 @@ """ from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from typing_extensions import override from multidict import CIMultiDict @@ -40,6 +40,7 @@ QueryTypes, Timeout, TimeoutTypes, + Unset, ) try: @@ -74,11 +75,14 @@ def __init__( self._version = HTTPVersion(version) if isinstance(timeout, Timeout): - self._timeout = httpx.Timeout( - timeout=timeout.total, - connect=timeout.connect, - read=timeout.read, - ) + timeout_kwargs: dict[str, Any] = {} + if not isinstance(timeout.total, Unset): + timeout_kwargs["timeout"] = timeout.total + if not isinstance(timeout.connect, Unset): + timeout_kwargs["connect"] = timeout.connect + if not isinstance(timeout.read, Unset): + timeout_kwargs["read"] = timeout.read + self._timeout = httpx.Timeout(**timeout_kwargs) else: self._timeout = httpx.Timeout(timeout) @@ -93,11 +97,14 @@ def client(self) -> httpx.AsyncClient: @override async def request(self, setup: Request) -> Response: if isinstance(setup.timeout, Timeout): - timeout = httpx.Timeout( - timeout=setup.timeout.total, - connect=setup.timeout.connect, - read=setup.timeout.read, - ) + timeout_kwargs: dict[str, Any] = {} + if not isinstance(setup.timeout.total, Unset): + timeout_kwargs["timeout"] = setup.timeout.total + if not isinstance(setup.timeout.connect, Unset): + timeout_kwargs["connect"] = setup.timeout.connect + if not isinstance(setup.timeout.read, Unset): + timeout_kwargs["read"] = setup.timeout.read + timeout = httpx.Timeout(**timeout_kwargs) else: timeout = httpx.Timeout(setup.timeout) @@ -129,11 +136,14 @@ async def stream_request( chunk_size: int = 1024, ) -> AsyncGenerator[Response, None]: if isinstance(setup.timeout, Timeout): - timeout = httpx.Timeout( - timeout=setup.timeout.total, - connect=setup.timeout.connect, - read=setup.timeout.read, - ) + timeout_kwargs: dict[str, Any] = {} + if not isinstance(setup.timeout.total, Unset): + timeout_kwargs["timeout"] = setup.timeout.total + if not isinstance(setup.timeout.connect, Unset): + timeout_kwargs["connect"] = setup.timeout.connect + if not isinstance(setup.timeout.read, Unset): + timeout_kwargs["read"] = setup.timeout.read + timeout = httpx.Timeout(**timeout_kwargs) else: timeout = httpx.Timeout(setup.timeout) diff --git a/nonebot/internal/driver/model.py b/nonebot/internal/driver/model.py index d4b10ebab43d..7bdb8c72437c 100644 --- a/nonebot/internal/driver/model.py +++ b/nonebot/internal/driver/model.py @@ -12,7 +12,7 @@ class Unset: - """Sentinel for unset timeout fields.""" + """Sentinel for unset fields.""" __slots__ = () _instance: Self | None = None From 577a1ed749ffca8deaaf51cea476a751544bbc05 Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Sat, 28 Mar 2026 15:45:38 +0800 Subject: [PATCH 08/21] :bug: Enhance timeout handling to allow None values in HTTPClientSession --- nonebot/drivers/httpx.py | 6 +-- tests/test_driver.py | 104 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/nonebot/drivers/httpx.py b/nonebot/drivers/httpx.py index 58b8516f642a..cf1f63357444 100644 --- a/nonebot/drivers/httpx.py +++ b/nonebot/drivers/httpx.py @@ -82,7 +82,7 @@ def __init__( timeout_kwargs["connect"] = timeout.connect if not isinstance(timeout.read, Unset): timeout_kwargs["read"] = timeout.read - self._timeout = httpx.Timeout(**timeout_kwargs) + self._timeout = httpx.Timeout(**timeout_kwargs) if timeout_kwargs else None else: self._timeout = httpx.Timeout(timeout) @@ -104,7 +104,7 @@ async def request(self, setup: Request) -> Response: timeout_kwargs["connect"] = setup.timeout.connect if not isinstance(setup.timeout.read, Unset): timeout_kwargs["read"] = setup.timeout.read - timeout = httpx.Timeout(**timeout_kwargs) + timeout = httpx.Timeout(**timeout_kwargs) if timeout_kwargs else None else: timeout = httpx.Timeout(setup.timeout) @@ -143,7 +143,7 @@ async def stream_request( timeout_kwargs["connect"] = setup.timeout.connect if not isinstance(setup.timeout.read, Unset): timeout_kwargs["read"] = setup.timeout.read - timeout = httpx.Timeout(**timeout_kwargs) + timeout = httpx.Timeout(**timeout_kwargs) if timeout_kwargs else None else: timeout = httpx.Timeout(setup.timeout) diff --git a/tests/test_driver.py b/tests/test_driver.py index 1b7a2a33b9d7..f690ec36b44a 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -10,6 +10,7 @@ from nonebot.adapters import Bot from nonebot.dependencies import Dependent from nonebot.drivers import ( + UNSET, URL, ASGIMixin, Driver, @@ -18,6 +19,7 @@ Request, Response, Timeout, + Unset, WebSocket, WebSocketClientMixin, WebSocketServerSetup, @@ -706,6 +708,108 @@ async def receive(self, timeout: float | None = None) -> WSMessage: # noqa: ASY await ws.receive() +def test_unset_sentinel(): + assert UNSET is Unset() + assert repr(UNSET) == "UNSET" + assert not UNSET + assert bool(UNSET) is False + + +def test_timeout_unset_vs_none(): + # default: all fields are UNSET + t = Timeout() + assert isinstance(t.total, Unset) + assert isinstance(t.connect, Unset) + assert isinstance(t.read, Unset) + assert isinstance(t.close, Unset) + + # explicitly set to None + t = Timeout(close=None) + assert t.close is None + assert not isinstance(t.close, Unset) + + # explicitly set to a value + t = Timeout(total=5.0, close=None) + assert t.total == 5.0 + assert t.close is None + assert isinstance(t.connect, Unset) + assert isinstance(t.read, Unset) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "driver", + [ + pytest.param("nonebot.drivers.httpx:Driver", id="httpx"), + pytest.param("nonebot.drivers.aiohttp:Driver", id="aiohttp"), + ], + indirect=True, +) +async def test_http_client_timeout_unset(driver: Driver, server_url: URL): + """HTTP requests work with fully unset, partial, and None timeout fields.""" + assert isinstance(driver, HTTPClientMixin) + + # all fields unset — library defaults should apply + request = Request("POST", server_url, content="test", timeout=Timeout()) + response = await driver.request(request) + assert response.status_code == 200 + + # only total set + request = Request("POST", server_url, content="test", timeout=Timeout(total=10.0)) + response = await driver.request(request) + assert response.status_code == 200 + + # explicit None (no timeout) + request = Request( + "POST", server_url, content="test", timeout=Timeout(total=None, read=None) + ) + response = await driver.request(request) + assert response.status_code == 200 + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "driver", + [ + pytest.param("nonebot.drivers.websockets:Driver", id="websockets"), + pytest.param("nonebot.drivers.aiohttp:Driver", id="aiohttp"), + ], + indirect=True, +) +async def test_websocket_client_timeout_unset(driver: Driver, server_url: URL): + """WebSocket connections work with fully unset, partial, and None timeout fields.""" + assert isinstance(driver, WebSocketClientMixin) + + ws_url = server_url.with_scheme("ws") + + # all fields unset + request = Request("GET", ws_url, timeout=Timeout()) + async with driver.websocket(request) as ws: + await ws.send("quit") + with pytest.raises(WebSocketClosed): + await ws.receive() + + await anyio.sleep(1) + + # close explicitly set to None (no close timeout) + request = Request("GET", ws_url, timeout=Timeout(close=None)) + async with driver.websocket(request) as ws: + await ws.send("quit") + with pytest.raises(WebSocketClosed): + await ws.receive() + + await anyio.sleep(1) + + # partial: only total set + request = Request("GET", ws_url, timeout=Timeout(total=10.0)) + async with driver.websocket(request) as ws: + await ws.send("quit") + with pytest.raises(WebSocketClosed): + await ws.receive() + + await anyio.sleep(1) + + @pytest.mark.parametrize( ("driver", "driver_type"), [ From 53468e79489b59e03f1bbac08819c31190427ca6 Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Sat, 28 Mar 2026 15:57:53 +0800 Subject: [PATCH 09/21] :white_check_mark: Add tests for stream_request and session timeout handling in test_driver.py --- tests/test_driver.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_driver.py b/tests/test_driver.py index f690ec36b44a..a3acc3cc0e38 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -766,6 +766,32 @@ async def test_http_client_timeout_unset(driver: Driver, server_url: URL): response = await driver.request(request) assert response.status_code == 200 + # stream_request with unset timeout + request = Request("POST", server_url, content="test", timeout=Timeout()) + async for resp in driver.stream_request(request, chunk_size=1024): + assert resp.status_code == 200 + + # stream_request with partial timeout + request = Request( + "POST", server_url, content="test", timeout=Timeout(total=10.0, read=None) + ) + async for resp in driver.stream_request(request, chunk_size=1024): + assert resp.status_code == 200 + + # session with Timeout object + session = driver.get_session(timeout=Timeout(total=10.0, connect=5.0, read=5.0)) + async with session: + request = Request("POST", server_url, content="test") + response = await session.request(request) + assert response.status_code == 200 + + # session with fully unset Timeout + session = driver.get_session(timeout=Timeout()) + async with session: + request = Request("POST", server_url, content="test") + response = await session.request(request) + assert response.status_code == 200 + @pytest.mark.anyio @pytest.mark.parametrize( @@ -809,6 +835,15 @@ async def test_websocket_client_timeout_unset(driver: Driver, server_url: URL): await anyio.sleep(1) + # read and close explicitly set + request = Request("GET", ws_url, timeout=Timeout(read=5.0, close=5.0)) + async with driver.websocket(request) as ws: + await ws.send("quit") + with pytest.raises(WebSocketClosed): + await ws.receive() + + await anyio.sleep(1) + @pytest.mark.parametrize( ("driver", "driver_type"), From 674c00d86352c50e34f7ce491a7c4fa1f7a6ee4f Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Mon, 30 Mar 2026 19:14:16 +0800 Subject: [PATCH 10/21] :bug: Refactor timeout handling in HTTPClientSession and Mixin classes to use httpx.USE_CLIENT_DEFAULT for better default timeout management --- nonebot/drivers/httpx.py | 18 +++++++++++++++--- nonebot/drivers/websockets.py | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/nonebot/drivers/httpx.py b/nonebot/drivers/httpx.py index cf1f63357444..ec93311d4772 100644 --- a/nonebot/drivers/httpx.py +++ b/nonebot/drivers/httpx.py @@ -82,7 +82,11 @@ def __init__( timeout_kwargs["connect"] = timeout.connect if not isinstance(timeout.read, Unset): timeout_kwargs["read"] = timeout.read - self._timeout = httpx.Timeout(**timeout_kwargs) if timeout_kwargs else None + self._timeout = ( + httpx.Timeout(**timeout_kwargs) + if timeout_kwargs + else httpx.USE_CLIENT_DEFAULT + ) else: self._timeout = httpx.Timeout(timeout) @@ -104,7 +108,11 @@ async def request(self, setup: Request) -> Response: timeout_kwargs["connect"] = setup.timeout.connect if not isinstance(setup.timeout.read, Unset): timeout_kwargs["read"] = setup.timeout.read - timeout = httpx.Timeout(**timeout_kwargs) if timeout_kwargs else None + timeout = ( + httpx.Timeout(**timeout_kwargs) + if timeout_kwargs + else httpx.USE_CLIENT_DEFAULT + ) else: timeout = httpx.Timeout(setup.timeout) @@ -143,7 +151,11 @@ async def stream_request( timeout_kwargs["connect"] = setup.timeout.connect if not isinstance(setup.timeout.read, Unset): timeout_kwargs["read"] = setup.timeout.read - timeout = httpx.Timeout(**timeout_kwargs) if timeout_kwargs else None + timeout = ( + httpx.Timeout(**timeout_kwargs) + if timeout_kwargs + else httpx.USE_CLIENT_DEFAULT + ) else: timeout = httpx.Timeout(setup.timeout) diff --git a/nonebot/drivers/websockets.py b/nonebot/drivers/websockets.py index 36e89279118b..81b2fd1028b8 100644 --- a/nonebot/drivers/websockets.py +++ b/nonebot/drivers/websockets.py @@ -88,7 +88,7 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: else: timeout_kwargs = { "open_timeout": setup.timeout, - "close_timeout": setup.timeout or 10.0, + "close_timeout": setup.timeout if setup.timeout is not None else 10.0, } connection = connect( From bc675e2a38471b49b20ead3b364974d8ab7bc99a Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Mon, 30 Mar 2026 19:17:29 +0800 Subject: [PATCH 11/21] :bug: Refactor timeout initialization in Mixin class to handle None values for ws_close timeout --- nonebot/drivers/aiohttp.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nonebot/drivers/aiohttp.py b/nonebot/drivers/aiohttp.py index 979ce16a228e..ab2b42bb4887 100644 --- a/nonebot/drivers/aiohttp.py +++ b/nonebot/drivers/aiohttp.py @@ -289,7 +289,9 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: timeout_kwargs["ws_close"] = ws_close timeout = aiohttp.ClientWSTimeout(**timeout_kwargs) # type: ignore else: - timeout = aiohttp.ClientWSTimeout(ws_close=setup.timeout or 10.0) # type: ignore + timeout = aiohttp.ClientWSTimeout( + ws_close=setup.timeout if setup.timeout is not None else 10.0 # type: ignore + ) async with aiohttp.ClientSession(version=version, trust_env=True) as session: async with session.ws_connect( From 86b9a487b4f05ce6ee6539180389f362f56fa4bf Mon Sep 17 00:00:00 2001 From: Ju4tCode <42488585+yanyongyu@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:18:14 +0800 Subject: [PATCH 12/21] :sparkles: refactor timeout logic --- nonebot/drivers/__init__.py | 4 +- nonebot/drivers/aiohttp.py | 75 ++++++++++++++++---------- nonebot/drivers/httpx.py | 84 ++++++++++++++++------------- nonebot/drivers/websockets.py | 25 ++++----- nonebot/internal/driver/__init__.py | 3 +- nonebot/internal/driver/model.py | 36 ++++--------- nonebot/utils.py | 38 +++++++++++++ 7 files changed, 153 insertions(+), 112 deletions(-) diff --git a/nonebot/drivers/__init__.py b/nonebot/drivers/__init__.py index 8e91d609ce91..b54f72cca3ee 100644 --- a/nonebot/drivers/__init__.py +++ b/nonebot/drivers/__init__.py @@ -9,7 +9,7 @@ description: nonebot.drivers 模块 """ -from nonebot.internal.driver import UNSET as UNSET +from nonebot.internal.driver import DEFAULT_TIMEOUT as DEFAULT_TIMEOUT from nonebot.internal.driver import URL as URL from nonebot.internal.driver import ASGIMixin as ASGIMixin from nonebot.internal.driver import Cookies as Cookies @@ -26,13 +26,13 @@ from nonebot.internal.driver import ReverseDriver as ReverseDriver from nonebot.internal.driver import ReverseMixin as ReverseMixin from nonebot.internal.driver import Timeout as Timeout -from nonebot.internal.driver import Unset as Unset from nonebot.internal.driver import WebSocket as WebSocket from nonebot.internal.driver import WebSocketClientMixin as WebSocketClientMixin from nonebot.internal.driver import WebSocketServerSetup as WebSocketServerSetup from nonebot.internal.driver import combine_driver as combine_driver __autodoc__ = { + "DEFAULT_TIMEOUT": True, "URL": True, "Cookies": True, "Request": True, diff --git a/nonebot/drivers/aiohttp.py b/nonebot/drivers/aiohttp.py index ab2b42bb4887..7a1c6de91c31 100644 --- a/nonebot/drivers/aiohttp.py +++ b/nonebot/drivers/aiohttp.py @@ -38,14 +38,15 @@ from nonebot.drivers.none import Driver as NoneDriver from nonebot.exception import WebSocketClosed from nonebot.internal.driver import ( + DEFAULT_TIMEOUT, Cookies, CookieTypes, HeaderTypes, QueryTypes, Timeout, TimeoutTypes, - Unset, ) +from nonebot.utils import UNSET, UnsetType try: import aiohttp @@ -64,7 +65,7 @@ def __init__( headers: HeaderTypes = None, cookies: CookieTypes = None, version: str | HTTPVersion = HTTPVersion.H11, - timeout: TimeoutTypes = None, + timeout: TimeoutTypes | UnsetType = UNSET, proxy: str | None = None, ): self._client: aiohttp.ClientSession | None = None @@ -86,17 +87,22 @@ def __init__( else: raise RuntimeError(f"Unsupported HTTP version: {version}") + timeout = DEFAULT_TIMEOUT if timeout is UNSET else timeout if isinstance(timeout, Timeout): timeout_kwargs: dict[str, Any] = {} - if not isinstance(timeout.total, Unset): + if timeout.total is not UNSET: timeout_kwargs["total"] = timeout.total - if not isinstance(timeout.connect, Unset): + if timeout.connect is not UNSET: timeout_kwargs["connect"] = timeout.connect - if not isinstance(timeout.read, Unset): + if timeout.read is not UNSET: timeout_kwargs["sock_read"] = timeout.read + if not timeout_kwargs: + timeout_kwargs["total"] = DEFAULT_TIMEOUT.total + timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect + timeout_kwargs["sock_read"] = DEFAULT_TIMEOUT.read self._timeout = aiohttp.ClientTimeout(**timeout_kwargs) else: - self._timeout = aiohttp.ClientTimeout(timeout) + self._timeout = aiohttp.ClientTimeout(connect=timeout, sock_read=timeout) self._proxy = proxy @@ -125,17 +131,22 @@ async def request(self, setup: Request) -> Response: if cookie.value is not None ) - if isinstance(setup.timeout, Timeout): + _timeout = self._timeout if setup.timeout is UNSET else setup.timeout + if isinstance(_timeout, Timeout): timeout_kwargs: dict[str, Any] = {} - if not isinstance(setup.timeout.total, Unset): - timeout_kwargs["total"] = setup.timeout.total - if not isinstance(setup.timeout.connect, Unset): - timeout_kwargs["connect"] = setup.timeout.connect - if not isinstance(setup.timeout.read, Unset): - timeout_kwargs["sock_read"] = setup.timeout.read + if _timeout.total is not UNSET: + timeout_kwargs["total"] = _timeout.total + if _timeout.connect is not UNSET: + timeout_kwargs["connect"] = _timeout.connect + if _timeout.read is not UNSET: + timeout_kwargs["sock_read"] = _timeout.read + if not timeout_kwargs: + timeout_kwargs["total"] = DEFAULT_TIMEOUT.total + timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect + timeout_kwargs["sock_read"] = DEFAULT_TIMEOUT.read timeout = aiohttp.ClientTimeout(**timeout_kwargs) else: - timeout = aiohttp.ClientTimeout(setup.timeout) + timeout = aiohttp.ClientTimeout(connect=_timeout, sock_read=_timeout) async with await self.client.request( setup.method, @@ -178,17 +189,22 @@ async def stream_request( if cookie.value is not None ) - if isinstance(setup.timeout, Timeout): + _timeout = self._timeout if setup.timeout is UNSET else setup.timeout + if isinstance(_timeout, Timeout): timeout_kwargs: dict[str, Any] = {} - if not isinstance(setup.timeout.total, Unset): - timeout_kwargs["total"] = setup.timeout.total - if not isinstance(setup.timeout.connect, Unset): - timeout_kwargs["connect"] = setup.timeout.connect - if not isinstance(setup.timeout.read, Unset): - timeout_kwargs["sock_read"] = setup.timeout.read + if _timeout.total is not UNSET: + timeout_kwargs["total"] = _timeout.total + if _timeout.connect is not UNSET: + timeout_kwargs["connect"] = _timeout.connect + if _timeout.read is not UNSET: + timeout_kwargs["sock_read"] = _timeout.read + if not timeout_kwargs: + timeout_kwargs["total"] = DEFAULT_TIMEOUT.total + timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect + timeout_kwargs["sock_read"] = DEFAULT_TIMEOUT.read timeout = aiohttp.ClientTimeout(**timeout_kwargs) else: - timeout = aiohttp.ClientTimeout(setup.timeout) + timeout = aiohttp.ClientTimeout(connect=_timeout, sock_read=_timeout) async with self.client.request( setup.method, @@ -280,17 +296,18 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: else: raise RuntimeError(f"Unsupported HTTP version: {setup.version}") - if isinstance(setup.timeout, Timeout): + _timeout = DEFAULT_TIMEOUT if setup.timeout is UNSET else setup.timeout + if isinstance(_timeout, Timeout): timeout_kwargs: dict[str, Any] = {} - if not isinstance(setup.timeout.read, Unset): - timeout_kwargs["ws_receive"] = setup.timeout.read - ws_close = setup.timeout.close or setup.timeout.total - if not isinstance(ws_close, Unset): + if _timeout.read is not UNSET: + timeout_kwargs["ws_receive"] = _timeout.read + ws_close = _timeout.total if _timeout.close is UNSET else _timeout.close + if ws_close is not UNSET: timeout_kwargs["ws_close"] = ws_close timeout = aiohttp.ClientWSTimeout(**timeout_kwargs) # type: ignore else: timeout = aiohttp.ClientWSTimeout( - ws_close=setup.timeout if setup.timeout is not None else 10.0 # type: ignore + ws_receive=_timeout, ws_close=_timeout ) async with aiohttp.ClientSession(version=version, trust_env=True) as session: @@ -310,7 +327,7 @@ def get_session( headers: HeaderTypes = None, cookies: CookieTypes = None, version: str | HTTPVersion = HTTPVersion.H11, - timeout: TimeoutTypes = None, + timeout: TimeoutTypes | UnsetType = UNSET, proxy: str | None = None, ) -> Session: return Session( diff --git a/nonebot/drivers/httpx.py b/nonebot/drivers/httpx.py index ec93311d4772..f5da0269a414 100644 --- a/nonebot/drivers/httpx.py +++ b/nonebot/drivers/httpx.py @@ -34,14 +34,15 @@ ) from nonebot.drivers.none import Driver as NoneDriver from nonebot.internal.driver import ( + DEFAULT_TIMEOUT, Cookies, CookieTypes, HeaderTypes, QueryTypes, Timeout, TimeoutTypes, - Unset, ) +from nonebot.utils import UNSET, UnsetType try: import httpx @@ -60,7 +61,7 @@ def __init__( headers: HeaderTypes = None, cookies: CookieTypes = None, version: str | HTTPVersion = HTTPVersion.H11, - timeout: TimeoutTypes = None, + timeout: TimeoutTypes | UnsetType = UNSET, proxy: str | None = None, ): self._client: httpx.AsyncClient | None = None @@ -74,19 +75,22 @@ def __init__( self._cookies = Cookies(cookies) self._version = HTTPVersion(version) + timeout = DEFAULT_TIMEOUT if timeout is UNSET else timeout if isinstance(timeout, Timeout): timeout_kwargs: dict[str, Any] = {} - if not isinstance(timeout.total, Unset): - timeout_kwargs["timeout"] = timeout.total - if not isinstance(timeout.connect, Unset): + if timeout.total is not UNSET: + avg_timeout = None if timeout.total is None else timeout.total / 4 + timeout_kwargs["timeout"] = avg_timeout + if timeout.connect is not UNSET: timeout_kwargs["connect"] = timeout.connect - if not isinstance(timeout.read, Unset): + if timeout.read is not UNSET: timeout_kwargs["read"] = timeout.read - self._timeout = ( - httpx.Timeout(**timeout_kwargs) - if timeout_kwargs - else httpx.USE_CLIENT_DEFAULT - ) + if not timeout_kwargs: + avg_timeout = None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 + timeout_kwargs["timeout"] = avg_timeout + timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect + timeout_kwargs["read"] = DEFAULT_TIMEOUT.read + self._timeout = httpx.Timeout(**timeout_kwargs) else: self._timeout = httpx.Timeout(timeout) @@ -100,21 +104,24 @@ def client(self) -> httpx.AsyncClient: @override async def request(self, setup: Request) -> Response: - if isinstance(setup.timeout, Timeout): + _timeout = self._timeout if setup.timeout is UNSET else setup.timeout + if isinstance(_timeout, Timeout): timeout_kwargs: dict[str, Any] = {} - if not isinstance(setup.timeout.total, Unset): - timeout_kwargs["timeout"] = setup.timeout.total - if not isinstance(setup.timeout.connect, Unset): - timeout_kwargs["connect"] = setup.timeout.connect - if not isinstance(setup.timeout.read, Unset): - timeout_kwargs["read"] = setup.timeout.read - timeout = ( - httpx.Timeout(**timeout_kwargs) - if timeout_kwargs - else httpx.USE_CLIENT_DEFAULT - ) + if _timeout.total is not UNSET: + avg_timeout = None if _timeout.total is None else _timeout.total / 4 + timeout_kwargs["timeout"] = avg_timeout + if _timeout.connect is not UNSET: + timeout_kwargs["connect"] = _timeout.connect + if _timeout.read is not UNSET: + timeout_kwargs["read"] = _timeout.read + if not timeout_kwargs: + avg_timeout = None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 + timeout_kwargs["timeout"] = avg_timeout + timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect + timeout_kwargs["read"] = DEFAULT_TIMEOUT.read + timeout = httpx.Timeout(**timeout_kwargs) else: - timeout = httpx.Timeout(setup.timeout) + timeout = httpx.Timeout(timeout) response = await self.client.request( setup.method, @@ -143,21 +150,24 @@ async def stream_request( *, chunk_size: int = 1024, ) -> AsyncGenerator[Response, None]: - if isinstance(setup.timeout, Timeout): + _timeout = self._timeout if setup.timeout is UNSET else setup.timeout + if isinstance(_timeout, Timeout): timeout_kwargs: dict[str, Any] = {} - if not isinstance(setup.timeout.total, Unset): - timeout_kwargs["timeout"] = setup.timeout.total - if not isinstance(setup.timeout.connect, Unset): - timeout_kwargs["connect"] = setup.timeout.connect - if not isinstance(setup.timeout.read, Unset): - timeout_kwargs["read"] = setup.timeout.read - timeout = ( - httpx.Timeout(**timeout_kwargs) - if timeout_kwargs - else httpx.USE_CLIENT_DEFAULT - ) + if _timeout.total is not UNSET: + avg_timeout = None if _timeout.total is None else _timeout.total / 4 + timeout_kwargs["timeout"] = avg_timeout + if _timeout.connect is not UNSET: + timeout_kwargs["connect"] = _timeout.connect + if _timeout.read is not UNSET: + timeout_kwargs["read"] = _timeout.read + if not timeout_kwargs: + avg_timeout = None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 + timeout_kwargs["timeout"] = avg_timeout + timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect + timeout_kwargs["read"] = DEFAULT_TIMEOUT.read + timeout = httpx.Timeout(**timeout_kwargs) else: - timeout = httpx.Timeout(setup.timeout) + timeout = httpx.Timeout(timeout) async with self.client.stream( setup.method, diff --git a/nonebot/drivers/websockets.py b/nonebot/drivers/websockets.py index 81b2fd1028b8..99d7ecfa9a5b 100644 --- a/nonebot/drivers/websockets.py +++ b/nonebot/drivers/websockets.py @@ -25,17 +25,12 @@ from typing import TYPE_CHECKING, Any, TypeVar from typing_extensions import ParamSpec, override -from nonebot.drivers import ( - Request, - Timeout, - Unset, - WebSocketClientMixin, - combine_driver, -) +from nonebot.drivers import DEFAULT_TIMEOUT, Request, Timeout, WebSocketClientMixin, combine_driver from nonebot.drivers import WebSocket as BaseWebSocket from nonebot.drivers.none import Driver as NoneDriver from nonebot.exception import WebSocketClosed from nonebot.log import LoguruHandler +from nonebot.utils import UNSET, UnsetType try: from websockets import ClientConnection, ConnectionClosed, connect @@ -76,19 +71,17 @@ def type(self) -> str: @override @asynccontextmanager async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: - if isinstance(setup.timeout, Timeout): + timeout = DEFAULT_TIMEOUT if setup.timeout is UNSET else setup.timeout + if isinstance(timeout, Timeout): timeout_kwargs: dict[str, Any] = {} - open_timeout = ( - setup.timeout.total or setup.timeout.connect or setup.timeout.read - ) - if not isinstance(open_timeout, Unset): + open_timeout = timeout.connect or timeout.read or timeout.total + if open_timeout is not UNSET: timeout_kwargs["open_timeout"] = open_timeout - if not isinstance(setup.timeout.close, Unset): - timeout_kwargs["close_timeout"] = setup.timeout.close + if timeout.close is not UNSET: + timeout_kwargs["close_timeout"] = timeout.close else: timeout_kwargs = { - "open_timeout": setup.timeout, - "close_timeout": setup.timeout if setup.timeout is not None else 10.0, + "open_timeout": setup.timeout, "close_timeout": setup.timeout } connection = connect( diff --git a/nonebot/internal/driver/__init__.py b/nonebot/internal/driver/__init__.py index 168e6af4b18c..e250173ee06d 100644 --- a/nonebot/internal/driver/__init__.py +++ b/nonebot/internal/driver/__init__.py @@ -9,7 +9,7 @@ from .abstract import ReverseMixin as ReverseMixin from .abstract import WebSocketClientMixin as WebSocketClientMixin from .combine import combine_driver as combine_driver -from .model import UNSET as UNSET +from .model import DEFAULT_TIMEOUT as DEFAULT_TIMEOUT from .model import URL as URL from .model import ContentTypes as ContentTypes from .model import Cookies as Cookies @@ -30,6 +30,5 @@ from .model import SimpleQuery as SimpleQuery from .model import Timeout as Timeout from .model import TimeoutTypes as TimeoutTypes -from .model import Unset as Unset from .model import WebSocket as WebSocket from .model import WebSocketServerSetup as WebSocketServerSetup diff --git a/nonebot/internal/driver/model.py b/nonebot/internal/driver/model.py index 7bdb8c72437c..784f2afad8b2 100644 --- a/nonebot/internal/driver/model.py +++ b/nonebot/internal/driver/model.py @@ -10,36 +10,20 @@ from multidict import CIMultiDict from yarl import URL as URL - -class Unset: - """Sentinel for unset fields.""" - - __slots__ = () - _instance: Self | None = None - - def __new__(cls) -> Self: - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __repr__(self) -> str: - return "UNSET" - - def __bool__(self) -> bool: - return False - - -UNSET = Unset() +from nonebot.utils import UNSET, UnsetType @dataclass class Timeout: """Request 超时配置。""" - total: float | None | Unset = UNSET - connect: float | None | Unset = UNSET - read: float | None | Unset = UNSET - close: float | None | Unset = UNSET + total: float | None | UnsetType = UNSET + connect: float | None | UnsetType = UNSET + read: float | None | UnsetType = UNSET + close: float | None | UnsetType = UNSET + + +DEFAULT_TIMEOUT = Timeout(total=None, connect=5.0, read=30.0, close=10.0) RawURL: TypeAlias = tuple[bytes, bytes, int | None, bytes] @@ -91,7 +75,7 @@ def __init__( json: Any = None, files: FilesTypes = None, version: str | HTTPVersion = HTTPVersion.H11, - timeout: TimeoutTypes = None, + timeout: TimeoutTypes | UnsetType = UNSET, proxy: str | None = None, ): # method @@ -103,7 +87,7 @@ def __init__( # http version self.version: HTTPVersion = HTTPVersion(version) # timeout - self.timeout: TimeoutTypes = timeout + self.timeout: TimeoutTypes | UnsetType = timeout # proxy self.proxy: str | None = proxy diff --git a/nonebot/utils.py b/nonebot/utils.py index 95750b2c5a09..181886328430 100644 --- a/nonebot/utils.py +++ b/nonebot/utils.py @@ -29,6 +29,7 @@ Any, Generic, TypeVar, + final, get_args, get_origin, overload, @@ -49,6 +50,8 @@ type_has_args, ) +from .compat import custom_validation + P = ParamSpec("P") R = TypeVar("R") T = TypeVar("T") @@ -57,6 +60,41 @@ E = TypeVar("E", bound=BaseException) +@final +@custom_validation +class Unset(Enum): + _UNSET = "" + + def __repr__(self) -> str: + return "" + + def __str__(self) -> str: + return self.__repr__() + + def __bool__(self) -> Literal[False]: + return False + + def __copy__(self): + return self._UNSET + + def __deepcopy__(self, memo: dict[int, Any]): + return self._UNSET + + @classmethod + def __get_validators__(cls): + yield cls._validate + + @classmethod + def _validate(cls, value: Any): + if value is not cls._UNSET: + raise ValueError(f"{value!r} is not UNSET") + return value + +UnsetType: TypeAlias = Literal[Unset._UNSET] + +UNSET: final = Unset._UNSET + + def escape_tag(s: str) -> str: """用于记录带颜色日志时转义 `` 类型特殊标签 From fc33ae716575ab1ca8152d654bbf75bfd88fd828 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 03:18:25 +0000 Subject: [PATCH 13/21] :rotating_light: auto fix by pre-commit hooks --- nonebot/drivers/aiohttp.py | 4 +--- nonebot/drivers/httpx.py | 12 +++++++++--- nonebot/drivers/websockets.py | 13 ++++++++++--- nonebot/internal/driver/model.py | 1 - nonebot/utils.py | 1 + 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/nonebot/drivers/aiohttp.py b/nonebot/drivers/aiohttp.py index 7a1c6de91c31..7470287080f5 100644 --- a/nonebot/drivers/aiohttp.py +++ b/nonebot/drivers/aiohttp.py @@ -306,9 +306,7 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: timeout_kwargs["ws_close"] = ws_close timeout = aiohttp.ClientWSTimeout(**timeout_kwargs) # type: ignore else: - timeout = aiohttp.ClientWSTimeout( - ws_receive=_timeout, ws_close=_timeout - ) + timeout = aiohttp.ClientWSTimeout(ws_receive=_timeout, ws_close=_timeout) async with aiohttp.ClientSession(version=version, trust_env=True) as session: async with session.ws_connect( diff --git a/nonebot/drivers/httpx.py b/nonebot/drivers/httpx.py index f5da0269a414..054defbb8368 100644 --- a/nonebot/drivers/httpx.py +++ b/nonebot/drivers/httpx.py @@ -86,7 +86,9 @@ def __init__( if timeout.read is not UNSET: timeout_kwargs["read"] = timeout.read if not timeout_kwargs: - avg_timeout = None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 + avg_timeout = ( + None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 + ) timeout_kwargs["timeout"] = avg_timeout timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect timeout_kwargs["read"] = DEFAULT_TIMEOUT.read @@ -115,7 +117,9 @@ async def request(self, setup: Request) -> Response: if _timeout.read is not UNSET: timeout_kwargs["read"] = _timeout.read if not timeout_kwargs: - avg_timeout = None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 + avg_timeout = ( + None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 + ) timeout_kwargs["timeout"] = avg_timeout timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect timeout_kwargs["read"] = DEFAULT_TIMEOUT.read @@ -161,7 +165,9 @@ async def stream_request( if _timeout.read is not UNSET: timeout_kwargs["read"] = _timeout.read if not timeout_kwargs: - avg_timeout = None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 + avg_timeout = ( + None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 + ) timeout_kwargs["timeout"] = avg_timeout timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect timeout_kwargs["read"] = DEFAULT_TIMEOUT.read diff --git a/nonebot/drivers/websockets.py b/nonebot/drivers/websockets.py index 99d7ecfa9a5b..7cd11af0a98c 100644 --- a/nonebot/drivers/websockets.py +++ b/nonebot/drivers/websockets.py @@ -25,12 +25,18 @@ from typing import TYPE_CHECKING, Any, TypeVar from typing_extensions import ParamSpec, override -from nonebot.drivers import DEFAULT_TIMEOUT, Request, Timeout, WebSocketClientMixin, combine_driver +from nonebot.drivers import ( + DEFAULT_TIMEOUT, + Request, + Timeout, + WebSocketClientMixin, + combine_driver, +) from nonebot.drivers import WebSocket as BaseWebSocket from nonebot.drivers.none import Driver as NoneDriver from nonebot.exception import WebSocketClosed from nonebot.log import LoguruHandler -from nonebot.utils import UNSET, UnsetType +from nonebot.utils import UNSET try: from websockets import ClientConnection, ConnectionClosed, connect @@ -81,7 +87,8 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: timeout_kwargs["close_timeout"] = timeout.close else: timeout_kwargs = { - "open_timeout": setup.timeout, "close_timeout": setup.timeout + "open_timeout": setup.timeout, + "close_timeout": setup.timeout, } connection = connect( diff --git a/nonebot/internal/driver/model.py b/nonebot/internal/driver/model.py index 784f2afad8b2..53df09907b3c 100644 --- a/nonebot/internal/driver/model.py +++ b/nonebot/internal/driver/model.py @@ -4,7 +4,6 @@ from enum import Enum from http.cookiejar import Cookie, CookieJar from typing import IO, Any, TypeAlias -from typing_extensions import Self import urllib.request from multidict import CIMultiDict diff --git a/nonebot/utils.py b/nonebot/utils.py index 181886328430..f2a97bfa7556 100644 --- a/nonebot/utils.py +++ b/nonebot/utils.py @@ -90,6 +90,7 @@ def _validate(cls, value: Any): raise ValueError(f"{value!r} is not UNSET") return value + UnsetType: TypeAlias = Literal[Unset._UNSET] UNSET: final = Unset._UNSET From 2f9b3475072c6e8784d7565fa8bf2d08db978b5c Mon Sep 17 00:00:00 2001 From: Ju4tCode <42488585+yanyongyu@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:23:10 +0800 Subject: [PATCH 14/21] :white_check_mark: change test code --- tests/test_driver.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/tests/test_driver.py b/tests/test_driver.py index a3acc3cc0e38..e81cb105e531 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -10,7 +10,6 @@ from nonebot.adapters import Bot from nonebot.dependencies import Dependent from nonebot.drivers import ( - UNSET, URL, ASGIMixin, Driver, @@ -19,7 +18,6 @@ Request, Response, Timeout, - Unset, WebSocket, WebSocketClientMixin, WebSocketServerSetup, @@ -28,6 +26,7 @@ from nonebot.drivers.aiohttp import WebSocket as AiohttpWebSocket from nonebot.exception import WebSocketClosed from nonebot.params import Depends +from nonebot.utils import UNSET from utils import FakeAdapter @@ -708,32 +707,25 @@ async def receive(self, timeout: float | None = None) -> WSMessage: # noqa: ASY await ws.receive() -def test_unset_sentinel(): - assert UNSET is Unset() - assert repr(UNSET) == "UNSET" - assert not UNSET - assert bool(UNSET) is False - - def test_timeout_unset_vs_none(): # default: all fields are UNSET t = Timeout() - assert isinstance(t.total, Unset) - assert isinstance(t.connect, Unset) - assert isinstance(t.read, Unset) - assert isinstance(t.close, Unset) + assert t.total is UNSET + assert t.connect is UNSET + assert t.read is UNSET + assert t.close is UNSET # explicitly set to None t = Timeout(close=None) assert t.close is None - assert not isinstance(t.close, Unset) + assert t.close is not UNSET # explicitly set to a value t = Timeout(total=5.0, close=None) assert t.total == 5.0 assert t.close is None - assert isinstance(t.connect, Unset) - assert isinstance(t.read, Unset) + assert t.connect is UNSET + assert t.read is UNSET @pytest.mark.anyio From dbf199951c339657a324a50e9435914596916178 Mon Sep 17 00:00:00 2001 From: Ju4tCode <42488585+yanyongyu@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:24:58 +0800 Subject: [PATCH 15/21] :bug: fix missing import --- nonebot/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nonebot/utils.py b/nonebot/utils.py index f2a97bfa7556..58f932e2a48a 100644 --- a/nonebot/utils.py +++ b/nonebot/utils.py @@ -29,6 +29,7 @@ Any, Generic, TypeVar, + Literal, final, get_args, get_origin, From 2b777d64e853da6584b310ead1891c8fafcacef4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 03:25:05 +0000 Subject: [PATCH 16/21] :rotating_light: auto fix by pre-commit hooks --- nonebot/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nonebot/utils.py b/nonebot/utils.py index 58f932e2a48a..a46e96cd0742 100644 --- a/nonebot/utils.py +++ b/nonebot/utils.py @@ -28,8 +28,8 @@ from typing import ( Any, Generic, - TypeVar, Literal, + TypeVar, final, get_args, get_origin, From 6ea93f108c21679f612792607845b4731666a26d Mon Sep 17 00:00:00 2001 From: StarHeartHunt Date: Tue, 31 Mar 2026 11:54:35 +0800 Subject: [PATCH 17/21] :bug: Improve timeout handling in Session classes to support aiohttp.ClientTimeout and enhance initialization logic --- nonebot/drivers/aiohttp.py | 12 +++++++++--- nonebot/drivers/httpx.py | 10 +++++----- nonebot/utils.py | 5 ++++- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/nonebot/drivers/aiohttp.py b/nonebot/drivers/aiohttp.py index 7470287080f5..32b2a744392d 100644 --- a/nonebot/drivers/aiohttp.py +++ b/nonebot/drivers/aiohttp.py @@ -132,7 +132,10 @@ async def request(self, setup: Request) -> Response: ) _timeout = self._timeout if setup.timeout is UNSET else setup.timeout - if isinstance(_timeout, Timeout): + + if isinstance(_timeout, aiohttp.ClientTimeout): + timeout = _timeout + elif isinstance(_timeout, Timeout): timeout_kwargs: dict[str, Any] = {} if _timeout.total is not UNSET: timeout_kwargs["total"] = _timeout.total @@ -190,7 +193,10 @@ async def stream_request( ) _timeout = self._timeout if setup.timeout is UNSET else setup.timeout - if isinstance(_timeout, Timeout): + + if isinstance(_timeout, aiohttp.ClientTimeout): + timeout = _timeout + elif isinstance(_timeout, Timeout): timeout_kwargs: dict[str, Any] = {} if _timeout.total is not UNSET: timeout_kwargs["total"] = _timeout.total @@ -306,7 +312,7 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: timeout_kwargs["ws_close"] = ws_close timeout = aiohttp.ClientWSTimeout(**timeout_kwargs) # type: ignore else: - timeout = aiohttp.ClientWSTimeout(ws_receive=_timeout, ws_close=_timeout) + timeout = aiohttp.ClientWSTimeout(ws_receive=_timeout, ws_close=_timeout) # type: ignore async with aiohttp.ClientSession(version=version, trust_env=True) as session: async with session.ws_connect( diff --git a/nonebot/drivers/httpx.py b/nonebot/drivers/httpx.py index 054defbb8368..f42a48eef000 100644 --- a/nonebot/drivers/httpx.py +++ b/nonebot/drivers/httpx.py @@ -85,7 +85,7 @@ def __init__( timeout_kwargs["connect"] = timeout.connect if timeout.read is not UNSET: timeout_kwargs["read"] = timeout.read - if not timeout_kwargs: + if not timeout_kwargs and DEFAULT_TIMEOUT.total is not UNSET: avg_timeout = ( None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 ) @@ -116,7 +116,7 @@ async def request(self, setup: Request) -> Response: timeout_kwargs["connect"] = _timeout.connect if _timeout.read is not UNSET: timeout_kwargs["read"] = _timeout.read - if not timeout_kwargs: + if not timeout_kwargs and DEFAULT_TIMEOUT.total is not UNSET: avg_timeout = ( None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 ) @@ -125,7 +125,7 @@ async def request(self, setup: Request) -> Response: timeout_kwargs["read"] = DEFAULT_TIMEOUT.read timeout = httpx.Timeout(**timeout_kwargs) else: - timeout = httpx.Timeout(timeout) + timeout = httpx.Timeout(_timeout) response = await self.client.request( setup.method, @@ -164,7 +164,7 @@ async def stream_request( timeout_kwargs["connect"] = _timeout.connect if _timeout.read is not UNSET: timeout_kwargs["read"] = _timeout.read - if not timeout_kwargs: + if not timeout_kwargs and DEFAULT_TIMEOUT.total is not UNSET: avg_timeout = ( None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 ) @@ -173,7 +173,7 @@ async def stream_request( timeout_kwargs["read"] = DEFAULT_TIMEOUT.read timeout = httpx.Timeout(**timeout_kwargs) else: - timeout = httpx.Timeout(timeout) + timeout = httpx.Timeout(_timeout) async with self.client.stream( setup.method, diff --git a/nonebot/utils.py b/nonebot/utils.py index a46e96cd0742..71755af61443 100644 --- a/nonebot/utils.py +++ b/nonebot/utils.py @@ -19,6 +19,7 @@ import contextlib from contextlib import AbstractContextManager, asynccontextmanager import dataclasses +from enum import Enum from functools import partial, wraps import importlib import inspect @@ -27,8 +28,10 @@ import re from typing import ( Any, + Final, Generic, Literal, + TypeAlias, TypeVar, final, get_args, @@ -94,7 +97,7 @@ def _validate(cls, value: Any): UnsetType: TypeAlias = Literal[Unset._UNSET] -UNSET: final = Unset._UNSET +UNSET: Final[UnsetType] = Unset._UNSET def escape_tag(s: str) -> str: From 0530795371917496a54d526bdbbab8cddcb09e9f Mon Sep 17 00:00:00 2001 From: Ju4tCode <42488585+yanyongyu@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:28:46 +0800 Subject: [PATCH 18/21] :sparkles: improve timeout handle --- nonebot/drivers/aiohttp.py | 143 +++++++++++++++------------- nonebot/drivers/httpx.py | 111 ++++++++++----------- nonebot/drivers/websockets.py | 29 ++++-- nonebot/internal/driver/abstract.py | 4 +- nonebot/utils.py | 12 +++ 5 files changed, 156 insertions(+), 143 deletions(-) diff --git a/nonebot/drivers/aiohttp.py b/nonebot/drivers/aiohttp.py index 32b2a744392d..74fad8a57f4e 100644 --- a/nonebot/drivers/aiohttp.py +++ b/nonebot/drivers/aiohttp.py @@ -46,7 +46,7 @@ Timeout, TimeoutTypes, ) -from nonebot.utils import UNSET, UnsetType +from nonebot.utils import UNSET, UnsetType, exclude_unset try: import aiohttp @@ -87,23 +87,32 @@ def __init__( else: raise RuntimeError(f"Unsupported HTTP version: {version}") - timeout = DEFAULT_TIMEOUT if timeout is UNSET else timeout + _timeout = None if isinstance(timeout, Timeout): - timeout_kwargs: dict[str, Any] = {} - if timeout.total is not UNSET: - timeout_kwargs["total"] = timeout.total - if timeout.connect is not UNSET: - timeout_kwargs["connect"] = timeout.connect - if timeout.read is not UNSET: - timeout_kwargs["sock_read"] = timeout.read - if not timeout_kwargs: - timeout_kwargs["total"] = DEFAULT_TIMEOUT.total - timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect - timeout_kwargs["sock_read"] = DEFAULT_TIMEOUT.read - self._timeout = aiohttp.ClientTimeout(**timeout_kwargs) - else: - self._timeout = aiohttp.ClientTimeout(connect=timeout, sock_read=timeout) + timeout_kwargs: dict[str, float | None] = exclude_unset( + { + "total": timeout.total, + "connect": timeout.connect, + "sock_read": timeout.read, + } + ) + if timeout_kwargs: + _timeout = aiohttp.ClientTimeout(**timeout_kwargs) + elif timeout is not UNSET: + _timeout = aiohttp.ClientTimeout(connect=timeout, sock_read=timeout) + + if _timeout is None: + _timeout = aiohttp.ClientTimeout( + **exclude_unset( + { + "total": DEFAULT_TIMEOUT.total, + "connect": DEFAULT_TIMEOUT.connect, + "sock_read": DEFAULT_TIMEOUT.read, + } + ) + ) + self._timeout = _timeout self._proxy = proxy @property @@ -112,6 +121,25 @@ def client(self) -> aiohttp.ClientSession: raise RuntimeError("Session is not initialized") return self._client + def _get_timeout(self, timeout: TimeoutTypes | UnsetType) -> aiohttp.ClientTimeout: + _timeout = None + if isinstance(timeout, Timeout): + timeout_kwargs: dict[str, float | None] = exclude_unset( + { + "total": timeout.total, + "connect": timeout.connect, + "sock_read": timeout.read, + } + ) + if timeout_kwargs: + _timeout = aiohttp.ClientTimeout(**timeout_kwargs) + elif timeout is not UNSET: + _timeout = aiohttp.ClientTimeout(connect=timeout, sock_read=timeout) + + if _timeout is None: + return self._timeout + return _timeout + @override async def request(self, setup: Request) -> Response: if self._params: @@ -131,26 +159,6 @@ async def request(self, setup: Request) -> Response: if cookie.value is not None ) - _timeout = self._timeout if setup.timeout is UNSET else setup.timeout - - if isinstance(_timeout, aiohttp.ClientTimeout): - timeout = _timeout - elif isinstance(_timeout, Timeout): - timeout_kwargs: dict[str, Any] = {} - if _timeout.total is not UNSET: - timeout_kwargs["total"] = _timeout.total - if _timeout.connect is not UNSET: - timeout_kwargs["connect"] = _timeout.connect - if _timeout.read is not UNSET: - timeout_kwargs["sock_read"] = _timeout.read - if not timeout_kwargs: - timeout_kwargs["total"] = DEFAULT_TIMEOUT.total - timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect - timeout_kwargs["sock_read"] = DEFAULT_TIMEOUT.read - timeout = aiohttp.ClientTimeout(**timeout_kwargs) - else: - timeout = aiohttp.ClientTimeout(connect=_timeout, sock_read=_timeout) - async with await self.client.request( setup.method, url, @@ -159,7 +167,7 @@ async def request(self, setup: Request) -> Response: cookies=cookies, headers=setup.headers, proxy=setup.proxy or self._proxy, - timeout=timeout, + timeout=self._get_timeout(setup.timeout), ) as response: return Response( response.status, @@ -192,26 +200,6 @@ async def stream_request( if cookie.value is not None ) - _timeout = self._timeout if setup.timeout is UNSET else setup.timeout - - if isinstance(_timeout, aiohttp.ClientTimeout): - timeout = _timeout - elif isinstance(_timeout, Timeout): - timeout_kwargs: dict[str, Any] = {} - if _timeout.total is not UNSET: - timeout_kwargs["total"] = _timeout.total - if _timeout.connect is not UNSET: - timeout_kwargs["connect"] = _timeout.connect - if _timeout.read is not UNSET: - timeout_kwargs["sock_read"] = _timeout.read - if not timeout_kwargs: - timeout_kwargs["total"] = DEFAULT_TIMEOUT.total - timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect - timeout_kwargs["sock_read"] = DEFAULT_TIMEOUT.read - timeout = aiohttp.ClientTimeout(**timeout_kwargs) - else: - timeout = aiohttp.ClientTimeout(connect=_timeout, sock_read=_timeout) - async with self.client.request( setup.method, url, @@ -220,7 +208,7 @@ async def stream_request( cookies=cookies, headers=setup.headers, proxy=setup.proxy or self._proxy, - timeout=timeout, + timeout=self._get_timeout(setup.timeout), ) as response: response_headers = response.headers.copy() # aiohttp does not guarantee fixed-size chunks; re-chunk to exact size @@ -302,18 +290,37 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: else: raise RuntimeError(f"Unsupported HTTP version: {setup.version}") - _timeout = DEFAULT_TIMEOUT if setup.timeout is UNSET else setup.timeout - if isinstance(_timeout, Timeout): - timeout_kwargs: dict[str, Any] = {} - if _timeout.read is not UNSET: - timeout_kwargs["ws_receive"] = _timeout.read - ws_close = _timeout.total if _timeout.close is UNSET else _timeout.close - if ws_close is not UNSET: - timeout_kwargs["ws_close"] = ws_close - timeout = aiohttp.ClientWSTimeout(**timeout_kwargs) # type: ignore - else: + timeout = None + if isinstance(setup.timeout, Timeout): + timeout_kwargs: dict[str, float | None] = exclude_unset( + { + "ws_receive": setup.timeout.read, + "ws_close": ( + setup.timeout.total + if setup.timeout.close is UNSET + else setup.timeout.close + ), + } + ) + if timeout_kwargs: + timeout = aiohttp.ClientWSTimeout(**timeout_kwargs) # type: ignore + elif setup.timeout is not UNSET: timeout = aiohttp.ClientWSTimeout(ws_receive=_timeout, ws_close=_timeout) # type: ignore + if timeout is None: + timeout = aiohttp.ClientWSTimeout( + **exclude_unset( + { + "ws_receive": DEFAULT_TIMEOUT.read, + "ws_close": ( + DEFAULT_TIMEOUT.total + if DEFAULT_TIMEOUT.close is UNSET + else DEFAULT_TIMEOUT.close + ), + } + ) + ) + async with aiohttp.ClientSession(version=version, trust_env=True) as session: async with session.ws_connect( setup.url, diff --git a/nonebot/drivers/httpx.py b/nonebot/drivers/httpx.py index f42a48eef000..82cb77833430 100644 --- a/nonebot/drivers/httpx.py +++ b/nonebot/drivers/httpx.py @@ -42,7 +42,7 @@ Timeout, TimeoutTypes, ) -from nonebot.utils import UNSET, UnsetType +from nonebot.utils import UNSET, UnsetType, exclude_unset try: import httpx @@ -75,27 +75,34 @@ def __init__( self._cookies = Cookies(cookies) self._version = HTTPVersion(version) - timeout = DEFAULT_TIMEOUT if timeout is UNSET else timeout + _timeout = None if isinstance(timeout, Timeout): - timeout_kwargs: dict[str, Any] = {} - if timeout.total is not UNSET: - avg_timeout = None if timeout.total is None else timeout.total / 4 - timeout_kwargs["timeout"] = avg_timeout - if timeout.connect is not UNSET: - timeout_kwargs["connect"] = timeout.connect - if timeout.read is not UNSET: - timeout_kwargs["read"] = timeout.read - if not timeout_kwargs and DEFAULT_TIMEOUT.total is not UNSET: - avg_timeout = ( - None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 + avg_timeout = timeout.total and timeout.total / 4 + timeout_kwargs: dict[str, float | None] = exclude_unset( + { + "timeout": avg_timeout, + "connect": timeout.connect, + "read": timeout.read, + } + ) + if timeout_kwargs: + _timeout = httpx.Timeout(**timeout_kwargs) + elif timeout is not UNSET: + _timeout = httpx.Timeout(timeout) + + if _timeout is None: + avg_timeout = DEFAULT_TIMEOUT.total and DEFAULT_TIMEOUT.total / 4 + _timeout = httpx.Timeout( + **exclude_unset( + { + "timeout": avg_timeout, + "connect": DEFAULT_TIMEOUT.connect, + "read": DEFAULT_TIMEOUT.read, + } ) - timeout_kwargs["timeout"] = avg_timeout - timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect - timeout_kwargs["read"] = DEFAULT_TIMEOUT.read - self._timeout = httpx.Timeout(**timeout_kwargs) - else: - self._timeout = httpx.Timeout(timeout) + ) + self._timeout = timeout self._proxy = proxy @property @@ -104,29 +111,28 @@ def client(self) -> httpx.AsyncClient: raise RuntimeError("Session is not initialized") return self._client + def _get_timeout(self, timeout: TimeoutTypes | UnsetType) -> httpx.Timeout: + _timeout = None + if isinstance(timeout, Timeout): + avg_timeout = timeout.total and timeout.total / 4 + timeout_kwargs: dict[str, float | None] = exclude_unset( + { + "timeout": avg_timeout, + "connect": timeout.connect, + "read": timeout.read, + } + ) + if timeout_kwargs: + _timeout = httpx.Timeout(**timeout_kwargs) + elif timeout is not UNSET: + _timeout = httpx.Timeout(timeout) + + if _timeout is None: + return self._timeout + return _timeout + @override async def request(self, setup: Request) -> Response: - _timeout = self._timeout if setup.timeout is UNSET else setup.timeout - if isinstance(_timeout, Timeout): - timeout_kwargs: dict[str, Any] = {} - if _timeout.total is not UNSET: - avg_timeout = None if _timeout.total is None else _timeout.total / 4 - timeout_kwargs["timeout"] = avg_timeout - if _timeout.connect is not UNSET: - timeout_kwargs["connect"] = _timeout.connect - if _timeout.read is not UNSET: - timeout_kwargs["read"] = _timeout.read - if not timeout_kwargs and DEFAULT_TIMEOUT.total is not UNSET: - avg_timeout = ( - None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 - ) - timeout_kwargs["timeout"] = avg_timeout - timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect - timeout_kwargs["read"] = DEFAULT_TIMEOUT.read - timeout = httpx.Timeout(**timeout_kwargs) - else: - timeout = httpx.Timeout(_timeout) - response = await self.client.request( setup.method, str(setup.url), @@ -138,7 +144,7 @@ async def request(self, setup: Request) -> Response: params=setup.url.raw_query_string, headers=tuple(setup.headers.items()), cookies=setup.cookies.jar, - timeout=timeout, + timeout=self._get_timeout(setup.timeout), ) return Response( response.status_code, @@ -154,27 +160,6 @@ async def stream_request( *, chunk_size: int = 1024, ) -> AsyncGenerator[Response, None]: - _timeout = self._timeout if setup.timeout is UNSET else setup.timeout - if isinstance(_timeout, Timeout): - timeout_kwargs: dict[str, Any] = {} - if _timeout.total is not UNSET: - avg_timeout = None if _timeout.total is None else _timeout.total / 4 - timeout_kwargs["timeout"] = avg_timeout - if _timeout.connect is not UNSET: - timeout_kwargs["connect"] = _timeout.connect - if _timeout.read is not UNSET: - timeout_kwargs["read"] = _timeout.read - if not timeout_kwargs and DEFAULT_TIMEOUT.total is not UNSET: - avg_timeout = ( - None if DEFAULT_TIMEOUT.total is None else DEFAULT_TIMEOUT.total / 4 - ) - timeout_kwargs["timeout"] = avg_timeout - timeout_kwargs["connect"] = DEFAULT_TIMEOUT.connect - timeout_kwargs["read"] = DEFAULT_TIMEOUT.read - timeout = httpx.Timeout(**timeout_kwargs) - else: - timeout = httpx.Timeout(_timeout) - async with self.client.stream( setup.method, str(setup.url), @@ -186,7 +171,7 @@ async def stream_request( params=setup.url.raw_query_string, headers=tuple(setup.headers.items()), cookies=setup.cookies.jar, - timeout=timeout, + timeout=self._get_timeout(setup.timeout), ) as response: response_headers = response.headers.multi_items() async for chunk in response.aiter_bytes(chunk_size=chunk_size): diff --git a/nonebot/drivers/websockets.py b/nonebot/drivers/websockets.py index 7cd11af0a98c..0b473445fb92 100644 --- a/nonebot/drivers/websockets.py +++ b/nonebot/drivers/websockets.py @@ -36,7 +36,7 @@ from nonebot.drivers.none import Driver as NoneDriver from nonebot.exception import WebSocketClosed from nonebot.log import LoguruHandler -from nonebot.utils import UNSET +from nonebot.utils import UNSET, exclude_unset try: from websockets import ClientConnection, ConnectionClosed, connect @@ -77,19 +77,28 @@ def type(self) -> str: @override @asynccontextmanager async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: - timeout = DEFAULT_TIMEOUT if setup.timeout is UNSET else setup.timeout - if isinstance(timeout, Timeout): - timeout_kwargs: dict[str, Any] = {} - open_timeout = timeout.connect or timeout.read or timeout.total - if open_timeout is not UNSET: - timeout_kwargs["open_timeout"] = open_timeout - if timeout.close is not UNSET: - timeout_kwargs["close_timeout"] = timeout.close - else: + if isinstance(setup.timeout, Timeout): + open_timeout = setup.timeout.connect or setup.timeout.read or setup.timeout.total + timeout_kwargs: dict[str, float | None] = exclude_unset( + { + "open_timeout": open_timeout, + "close_timeout": setup.timeout.close + } + ) + elif setup.timeout is not UNSET: timeout_kwargs = { "open_timeout": setup.timeout, "close_timeout": setup.timeout, } + + if not timeout_kwargs: + open_timeout = DEFAULT_TIMEOUT.connect or DEFAULT_TIMEOUT.read or DEFAULT_TIMEOUT.total + timeout_kwargs = exclude_unset( + { + "open_timeout": open_timeout, + "close_timeout": DEFAULT_TIMEOUT.close, + } + ) connection = connect( str(setup.url), diff --git a/nonebot/internal/driver/abstract.py b/nonebot/internal/driver/abstract.py index 7ead40a74b94..a2bbadd0a33e 100644 --- a/nonebot/internal/driver/abstract.py +++ b/nonebot/internal/driver/abstract.py @@ -19,7 +19,7 @@ T_BotDisconnectionHook, T_DependencyCache, ) -from nonebot.utils import escape_tag, flatten_exception_group, run_coro_with_catch +from nonebot.utils import UNSET, UnsetType, escape_tag, flatten_exception_group, run_coro_with_catch from ._lifespan import LIFESPAN_FUNC, Lifespan from .model import ( @@ -246,7 +246,7 @@ def __init__( headers: HeaderTypes = None, cookies: CookieTypes = None, version: str | HTTPVersion = HTTPVersion.H11, - timeout: TimeoutTypes = None, + timeout: TimeoutTypes | UnsetType = UNSET, proxy: str | None = None, ): raise NotImplementedError diff --git a/nonebot/utils.py b/nonebot/utils.py index 71755af61443..3e8dce2b94f8 100644 --- a/nonebot/utils.py +++ b/nonebot/utils.py @@ -100,6 +100,18 @@ def _validate(cls, value: Any): UNSET: Final[UnsetType] = Unset._UNSET +def exclude_unset(data: Any) -> Any: + if isinstance(data, dict): + return data.__class__( + (k, exclude_unset(v)) for k, v in data.items() if v is not UNSET + ) + elif isinstance(data, list): + return data.__class__(exclude_unset(i) for i in data) + elif data is UNSET: + return None + return data + + def escape_tag(s: str) -> str: """用于记录带颜色日志时转义 `` 类型特殊标签 From 1fe53a5638c731d936594e263d74cdd2033c3ee8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 06:29:09 +0000 Subject: [PATCH 19/21] :rotating_light: auto fix by pre-commit hooks --- nonebot/drivers/aiohttp.py | 4 ++-- nonebot/drivers/httpx.py | 4 ++-- nonebot/drivers/websockets.py | 15 ++++++++------- nonebot/internal/driver/abstract.py | 8 +++++++- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/nonebot/drivers/aiohttp.py b/nonebot/drivers/aiohttp.py index 74fad8a57f4e..7e1817a992c5 100644 --- a/nonebot/drivers/aiohttp.py +++ b/nonebot/drivers/aiohttp.py @@ -19,7 +19,7 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from typing_extensions import override from multidict import CIMultiDict @@ -100,7 +100,7 @@ def __init__( _timeout = aiohttp.ClientTimeout(**timeout_kwargs) elif timeout is not UNSET: _timeout = aiohttp.ClientTimeout(connect=timeout, sock_read=timeout) - + if _timeout is None: _timeout = aiohttp.ClientTimeout( **exclude_unset( diff --git a/nonebot/drivers/httpx.py b/nonebot/drivers/httpx.py index 82cb77833430..cce1dd8f1c02 100644 --- a/nonebot/drivers/httpx.py +++ b/nonebot/drivers/httpx.py @@ -18,7 +18,7 @@ """ from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from typing_extensions import override from multidict import CIMultiDict @@ -126,7 +126,7 @@ def _get_timeout(self, timeout: TimeoutTypes | UnsetType) -> httpx.Timeout: _timeout = httpx.Timeout(**timeout_kwargs) elif timeout is not UNSET: _timeout = httpx.Timeout(timeout) - + if _timeout is None: return self._timeout return _timeout diff --git a/nonebot/drivers/websockets.py b/nonebot/drivers/websockets.py index 0b473445fb92..68c8b76ca312 100644 --- a/nonebot/drivers/websockets.py +++ b/nonebot/drivers/websockets.py @@ -78,21 +78,22 @@ def type(self) -> str: @asynccontextmanager async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: if isinstance(setup.timeout, Timeout): - open_timeout = setup.timeout.connect or setup.timeout.read or setup.timeout.total + open_timeout = ( + setup.timeout.connect or setup.timeout.read or setup.timeout.total + ) timeout_kwargs: dict[str, float | None] = exclude_unset( - { - "open_timeout": open_timeout, - "close_timeout": setup.timeout.close - } + {"open_timeout": open_timeout, "close_timeout": setup.timeout.close} ) elif setup.timeout is not UNSET: timeout_kwargs = { "open_timeout": setup.timeout, "close_timeout": setup.timeout, } - + if not timeout_kwargs: - open_timeout = DEFAULT_TIMEOUT.connect or DEFAULT_TIMEOUT.read or DEFAULT_TIMEOUT.total + open_timeout = ( + DEFAULT_TIMEOUT.connect or DEFAULT_TIMEOUT.read or DEFAULT_TIMEOUT.total + ) timeout_kwargs = exclude_unset( { "open_timeout": open_timeout, diff --git a/nonebot/internal/driver/abstract.py b/nonebot/internal/driver/abstract.py index a2bbadd0a33e..d68d126598e9 100644 --- a/nonebot/internal/driver/abstract.py +++ b/nonebot/internal/driver/abstract.py @@ -19,7 +19,13 @@ T_BotDisconnectionHook, T_DependencyCache, ) -from nonebot.utils import UNSET, UnsetType, escape_tag, flatten_exception_group, run_coro_with_catch +from nonebot.utils import ( + UNSET, + UnsetType, + escape_tag, + flatten_exception_group, + run_coro_with_catch, +) from ._lifespan import LIFESPAN_FUNC, Lifespan from .model import ( From 35e6f48b2983a6c9e044aef81e82471539f032c6 Mon Sep 17 00:00:00 2001 From: Ju4tCode <42488585+yanyongyu@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:19:08 +0000 Subject: [PATCH 20/21] :bug: fix errors --- nonebot/drivers/aiohttp.py | 11 +++++++---- nonebot/drivers/httpx.py | 2 +- nonebot/drivers/websockets.py | 5 +++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/nonebot/drivers/aiohttp.py b/nonebot/drivers/aiohttp.py index 7e1817a992c5..f558e335d940 100644 --- a/nonebot/drivers/aiohttp.py +++ b/nonebot/drivers/aiohttp.py @@ -97,7 +97,7 @@ def __init__( } ) if timeout_kwargs: - _timeout = aiohttp.ClientTimeout(**timeout_kwargs) + _timeout = aiohttp.ClientTimeout(**timeout_kwargs) # type: ignore elif timeout is not UNSET: _timeout = aiohttp.ClientTimeout(connect=timeout, sock_read=timeout) @@ -132,7 +132,7 @@ def _get_timeout(self, timeout: TimeoutTypes | UnsetType) -> aiohttp.ClientTimeo } ) if timeout_kwargs: - _timeout = aiohttp.ClientTimeout(**timeout_kwargs) + _timeout = aiohttp.ClientTimeout(**timeout_kwargs) # type: ignore elif timeout is not UNSET: _timeout = aiohttp.ClientTimeout(connect=timeout, sock_read=timeout) @@ -303,9 +303,12 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: } ) if timeout_kwargs: - timeout = aiohttp.ClientWSTimeout(**timeout_kwargs) # type: ignore + timeout = aiohttp.ClientWSTimeout(**timeout_kwargs) elif setup.timeout is not UNSET: - timeout = aiohttp.ClientWSTimeout(ws_receive=_timeout, ws_close=_timeout) # type: ignore + timeout = aiohttp.ClientWSTimeout( + ws_receive=setup.timeout, # type: ignore + ws_close=setup.timeout, # type: ignore + ) if timeout is None: timeout = aiohttp.ClientWSTimeout( diff --git a/nonebot/drivers/httpx.py b/nonebot/drivers/httpx.py index cce1dd8f1c02..a8541ad0294f 100644 --- a/nonebot/drivers/httpx.py +++ b/nonebot/drivers/httpx.py @@ -102,7 +102,7 @@ def __init__( ) ) - self._timeout = timeout + self._timeout = _timeout self._proxy = proxy @property diff --git a/nonebot/drivers/websockets.py b/nonebot/drivers/websockets.py index 68c8b76ca312..325c0c34c69a 100644 --- a/nonebot/drivers/websockets.py +++ b/nonebot/drivers/websockets.py @@ -77,11 +77,12 @@ def type(self) -> str: @override @asynccontextmanager async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: + timeout_kwargs: dict[str, float | None] = {} if isinstance(setup.timeout, Timeout): open_timeout = ( setup.timeout.connect or setup.timeout.read or setup.timeout.total ) - timeout_kwargs: dict[str, float | None] = exclude_unset( + timeout_kwargs = exclude_unset( {"open_timeout": open_timeout, "close_timeout": setup.timeout.close} ) elif setup.timeout is not UNSET: @@ -105,7 +106,7 @@ async def websocket(self, setup: Request) -> AsyncGenerator["WebSocket", None]: str(setup.url), additional_headers={**setup.headers, **setup.cookies.as_header(setup)}, proxy=setup.proxy if setup.proxy is not None else True, - **timeout_kwargs, + **timeout_kwargs, # type: ignore ) async with connection as ws: yield WebSocket(request=setup, websocket=ws) From 17de6fa4d21d8fee6b3e002554fef2133371265a Mon Sep 17 00:00:00 2001 From: Ju4tCode <42488585+yanyongyu@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:18:41 +0000 Subject: [PATCH 21/21] :white_check_mark: improve tests --- nonebot/utils.py | 2 +- tests/test_driver.py | 89 ++++++++++++++++++++++++++++++++------------ tests/test_utils.py | 33 ++++++++++++++++ 3 files changed, 99 insertions(+), 25 deletions(-) diff --git a/nonebot/utils.py b/nonebot/utils.py index 3e8dce2b94f8..872d92a22052 100644 --- a/nonebot/utils.py +++ b/nonebot/utils.py @@ -106,7 +106,7 @@ def exclude_unset(data: Any) -> Any: (k, exclude_unset(v)) for k, v in data.items() if v is not UNSET ) elif isinstance(data, list): - return data.__class__(exclude_unset(i) for i in data) + return data.__class__(exclude_unset(i) for i in data if i is not UNSET) elif data is UNSET: return None return data diff --git a/tests/test_driver.py b/tests/test_driver.py index e81cb105e531..3dd954b7fee1 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -737,53 +737,94 @@ def test_timeout_unset_vs_none(): ], indirect=True, ) -async def test_http_client_timeout_unset(driver: Driver, server_url: URL): +async def test_http_client_timeout(driver: Driver, server_url: URL): """HTTP requests work with fully unset, partial, and None timeout fields.""" assert isinstance(driver, HTTPClientMixin) - # all fields unset — library defaults should apply + # timeout not set, default timeout should apply + request = Request("POST", server_url, content="test") + response = await driver.request(request) + assert response.status_code == 200 + async for resp in driver.stream_request(request, chunk_size=1024): + assert resp.status_code == 200 + + # timeout is float or none + request = Request("POST", server_url, content="test", timeout=10.0) + response = await driver.request(request) + assert response.status_code == 200 + async for resp in driver.stream_request(request, chunk_size=1024): + assert resp.status_code == 200 + + # all fields unset, default timeout should apply request = Request("POST", server_url, content="test", timeout=Timeout()) response = await driver.request(request) assert response.status_code == 200 + async for resp in driver.stream_request(request, chunk_size=1024): + assert resp.status_code == 200 # only total set request = Request("POST", server_url, content="test", timeout=Timeout(total=10.0)) response = await driver.request(request) assert response.status_code == 200 + async for resp in driver.stream_request(request, chunk_size=1024): + assert resp.status_code == 200 # explicit None (no timeout) request = Request( - "POST", server_url, content="test", timeout=Timeout(total=None, read=None) + "POST", + server_url, + content="test", + timeout=Timeout(total=None, connect=None, read=None), ) response = await driver.request(request) assert response.status_code == 200 - - # stream_request with unset timeout - request = Request("POST", server_url, content="test", timeout=Timeout()) async for resp in driver.stream_request(request, chunk_size=1024): assert resp.status_code == 200 - # stream_request with partial timeout - request = Request( - "POST", server_url, content="test", timeout=Timeout(total=10.0, read=None) - ) - async for resp in driver.stream_request(request, chunk_size=1024): - assert resp.status_code == 200 + # session with timeout not set + session = driver.get_session() + async with session: + request = Request("POST", server_url, content="test") + response = await session.request(request) + assert response.status_code == 200 - # session with Timeout object - session = driver.get_session(timeout=Timeout(total=10.0, connect=5.0, read=5.0)) + # session with float or none timeout + session = driver.get_session(timeout=10.0) async with session: request = Request("POST", server_url, content="test") response = await session.request(request) assert response.status_code == 200 - # session with fully unset Timeout + # session with fully unset timeout session = driver.get_session(timeout=Timeout()) async with session: request = Request("POST", server_url, content="test") response = await session.request(request) assert response.status_code == 200 + # session with timeout + session = driver.get_session(timeout=Timeout(total=10.0, connect=5.0, read=5.0)) + async with session: + request = Request("POST", server_url, content="test") + response = await session.request(request) + assert response.status_code == 200 + + # session with timeout override + session = driver.get_session(timeout=Timeout(total=10.0)) + async with session: + request = Request( + "POST", server_url, content="test", timeout=Timeout(total=20.0) + ) + response = await session.request(request) + assert response.status_code == 200 + + # session with timeout float override + session = driver.get_session(timeout=Timeout(total=10.0)) + async with session: + request = Request("POST", server_url, content="test", timeout=20.0) + response = await session.request(request) + assert response.status_code == 200 + @pytest.mark.anyio @pytest.mark.parametrize( @@ -794,14 +835,14 @@ async def test_http_client_timeout_unset(driver: Driver, server_url: URL): ], indirect=True, ) -async def test_websocket_client_timeout_unset(driver: Driver, server_url: URL): +async def test_websocket_client_timeout(driver: Driver, server_url: URL): """WebSocket connections work with fully unset, partial, and None timeout fields.""" assert isinstance(driver, WebSocketClientMixin) ws_url = server_url.with_scheme("ws") - # all fields unset - request = Request("GET", ws_url, timeout=Timeout()) + # timeout not set, default timeout should apply + request = Request("GET", ws_url) async with driver.websocket(request) as ws: await ws.send("quit") with pytest.raises(WebSocketClosed): @@ -809,8 +850,8 @@ async def test_websocket_client_timeout_unset(driver: Driver, server_url: URL): await anyio.sleep(1) - # close explicitly set to None (no close timeout) - request = Request("GET", ws_url, timeout=Timeout(close=None)) + # timeout is float or none + request = Request("GET", ws_url, timeout=10.0) async with driver.websocket(request) as ws: await ws.send("quit") with pytest.raises(WebSocketClosed): @@ -818,8 +859,8 @@ async def test_websocket_client_timeout_unset(driver: Driver, server_url: URL): await anyio.sleep(1) - # partial: only total set - request = Request("GET", ws_url, timeout=Timeout(total=10.0)) + # all fields unset, default timeout should apply + request = Request("GET", ws_url, timeout=Timeout()) async with driver.websocket(request) as ws: await ws.send("quit") with pytest.raises(WebSocketClosed): @@ -827,8 +868,8 @@ async def test_websocket_client_timeout_unset(driver: Driver, server_url: URL): await anyio.sleep(1) - # read and close explicitly set - request = Request("GET", ws_url, timeout=Timeout(read=5.0, close=5.0)) + # close explicitly set to None (no close timeout) + request = Request("GET", ws_url, timeout=Timeout(close=None)) async with driver.websocket(request) as ws: await ws.send("quit") with pytest.raises(WebSocketClosed): diff --git a/tests/test_utils.py b/tests/test_utils.py index 9636d8d6494f..d7aed5384cd1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,9 +1,19 @@ +import copy import json +import pickle from typing import ClassVar, Dict, List, Literal, TypeVar, Union # noqa: UP035 +from pydantic import ValidationError +import pytest + +from nonebot.compat import type_validate_python from nonebot.utils import ( + UNSET, DataclassEncoder, + Unset, + UnsetType, escape_tag, + exclude_unset, generic_check_issubclass, is_async_gen_callable, is_coroutine_callable, @@ -12,6 +22,29 @@ from utils import FakeMessage, FakeMessageSegment +def test_unset(): + assert isinstance(UNSET, Unset) + assert bool(UNSET) is False + assert copy.copy(UNSET) is UNSET + assert copy.deepcopy(UNSET) is UNSET + assert pickle.loads(pickle.dumps(UNSET)) is UNSET + assert type_validate_python(UnsetType, UNSET) is UNSET + + with pytest.raises(ValidationError): + type_validate_python(UnsetType, 123) + + +def test_exclude_unset(): + assert exclude_unset({"a": 1, "b": UNSET, "c": None, "d": {"x": UNSET}}) == { + "a": 1, + "c": None, + "d": {}, + } + assert exclude_unset([1, UNSET, None, {"x": UNSET}]) == [1, None, {}] + assert exclude_unset(UNSET) is None + assert exclude_unset(123) == 123 + + def test_loguru_escape_tag(): assert escape_tag("red") == r"\red\" assert escape_tag("white") == r"\white\"