Skip to content

Commit 2463bd6

Browse files
authored
Release 3.9.1 (#153)
3.9.1 (2024-10-13) ------------------ **Fixed** - Exception leak from urllib3-future when using WebSocket. - Enforcing HTTP/3 in an AsyncSession. (#152) - Adapter kwargs fallback to support old Requests extensions. - Type hint for ``Response.extension`` linked to the generic interface instead of the inherited ones. - Accessing WS over HTTP/2+ using the synchronous session object. **Misc** - Documentation improvement for in-memory certificates and WebSocket use cases. (#151) **Changed** - urllib3-future lower bound version is raised to 2.10.904 to ensure exception are properly translated into urllib3-future ones for WS.
2 parents 1d78e68 + 7f007a8 commit 2463bd6

12 files changed

+412
-22
lines changed

.github/workflows/run-tests.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ jobs:
2121
fail-fast: false
2222
matrix:
2323
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.9", "pypy-3.10"]
24-
os: [ubuntu-latest, macOS-13, windows-latest]
24+
os: [ubuntu-22.04, macOS-13, windows-latest]
2525
include:
2626
# pypy-3.7, pypy-3.8 may fail due to missing cryptography wheels. Adapting.
2727
- python-version: pypy-3.7
28-
os: ubuntu-latest
28+
os: ubuntu-22.04
2929
- python-version: pypy-3.8
30-
os: ubuntu-latest
30+
os: ubuntu-22.04
3131
- python-version: pypy-3.8
3232
os: macOS-13
3333

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,4 @@ repos:
2828
- id: mypy
2929
args: [--check-untyped-defs]
3030
exclude: 'tests/|noxfile.py'
31-
additional_dependencies: ['charset_normalizer', 'urllib3.future>=2.10.900', 'wassima>=1.0.1', 'idna', 'kiss_headers']
31+
additional_dependencies: ['charset_normalizer', 'urllib3.future>=2.10.903', 'wassima>=1.0.1', 'idna', 'kiss_headers']

HISTORY.md

+16
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
Release History
22
===============
33

4+
3.9.1 (2024-10-13)
5+
------------------
6+
7+
**Fixed**
8+
- Exception leak from urllib3-future when using WebSocket.
9+
- Enforcing HTTP/3 in an AsyncSession. (#152)
10+
- Adapter kwargs fallback to support old Requests extensions.
11+
- Type hint for ``Response.extension`` linked to the generic interface instead of the inherited ones.
12+
- Accessing WS over HTTP/2+ using the synchronous session object.
13+
14+
**Misc**
15+
- Documentation improvement for in-memory certificates and WebSocket use cases.
16+
17+
**Changed**
18+
- urllib3-future lower bound version is raised to 2.10.904 to ensure exception are properly translated into urllib3-future ones for WS.
19+
420
3.9.0 (2024-10-08)
521
------------------
622

docs/user/advanced.rst

+6-1
Original file line numberDiff line numberDiff line change
@@ -313,14 +313,19 @@ In-memory Certificates
313313
The ``cert=...`` and ``verify=...`` can actually take the certificates themselves. Niquests support
314314
in-memory certificates instead of file paths.
315315

316+
.. warning:: The mTLS (aka. ``cert=...``) using in-memory certificate only works with Linux, FreeBSD or OpenBSD. See https://urllib3future.readthedocs.io/en/latest/advanced-usage.html#in-memory-client-mtls-certificate for more. It works on all platforms if you are using HTTP/3 over QUIC.
317+
318+
.. note:: When leveraging in-memory certificate for mTLS (aka. ``cert=...``), you have two possible configurations: (cert, key) or (cert, key, password) you cannot pass (cert) having concatenated cert,key in a single string.
319+
316320
.. _ca-certificates:
317321

318322
CA Certificates
319323
---------------
320324

321325
Niquests uses certificates provided by the package `wassima`_. This allows for users
322326
to not care about root CAs. By default it is expected to use your operating system root CAs.
323-
You have nothing to do.
327+
You have nothing to do. If we were unable to access your OS truststore natively, (e.g. not Windows, not MacOS, not Linux), then
328+
we will fallback on the ``certifi`` bundle.
324329

325330
.. _HTTP persistent connection: https://en.wikipedia.org/wiki/HTTP_persistent_connection
326331
.. _connection pooling: https://urllib3.readthedocs.io/en/latest/reference/index.html#module-urllib3.connectionpool

docs/user/quickstart.rst

+129
Original file line numberDiff line numberDiff line change
@@ -1120,6 +1120,135 @@ Others
11201120
Every other features still applies with WebSocket, like proxies, happy eyeballs, thread/task safety, etc...
11211121
See relevant docs for more.
11221122

1123+
Example with Concurrency (Thread)
1124+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1125+
1126+
In the following example, we will see how to communicate with a WebSocket server that echo what we send to him.
1127+
We will use a Thread for the reads and the main thread for write operations.
1128+
1129+
See::
1130+
1131+
from __future__ import annotations
1132+
1133+
from niquests import Session, Response, ReadTimeout
1134+
from threading import Thread
1135+
from time import sleep
1136+
1137+
1138+
def pull_message_from_server(my_response: Response) -> None:
1139+
"""Read messages here."""
1140+
iteration_counter = 0
1141+
1142+
while my_response.extension.closed is False:
1143+
try:
1144+
# will block for 1s top
1145+
message = my_response.extension.next_payload()
1146+
1147+
if message is None: # server just closed the connection. exit.
1148+
print("received goaway from server")
1149+
return
1150+
1151+
print(f"received message: '{message}'")
1152+
except ReadTimeout: # if no message received within 1s
1153+
pass
1154+
1155+
sleep(1) # let some time for the write part to acquire the lock
1156+
iteration_counter += 1
1157+
1158+
# send a ping every four iteration
1159+
if iteration_counter % 4 == 0:
1160+
my_response.extension.ping()
1161+
print("ping sent")
1162+
1163+
if __name__ == "__main__":
1164+
1165+
with Session() as s:
1166+
# connect to websocket server "echo.websocket.org" with timeout of 1s (both read and connect)
1167+
resp = s.get("wss://echo.websocket.org", timeout=1)
1168+
1169+
if resp.status_code != 101:
1170+
exit(1)
1171+
1172+
t = Thread(target=pull_message_from_server, args=(resp,))
1173+
t.start()
1174+
1175+
# send messages here
1176+
for i in range(30):
1177+
to_send = f"Hello World {i}"
1178+
resp.extension.send_payload(to_send)
1179+
print(f"sent message: '{to_send}'")
1180+
sleep(1) # let some time for the read part to acquire the lock
1181+
1182+
# exit gently!
1183+
resp.extension.close()
1184+
1185+
# wait for thread proper exit.
1186+
t.join()
1187+
1188+
print("program ended!")
1189+
1190+
1191+
.. warning:: The sleep serve the purpose to relax the lock on either the read or write side, so that one would not block the other forever.
1192+
1193+
Example with Concurrency (Async)
1194+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1195+
1196+
The same example as before, but using async instead.
1197+
1198+
See::
1199+
1200+
import asyncio
1201+
from niquests import AsyncSession, ReadTimeout, Response
1202+
1203+
async def read_from_ws(my_response: Response) -> None:
1204+
iteration_counter = 0
1205+
1206+
while my_response.extension.closed is False:
1207+
try:
1208+
# will block for 1s top
1209+
message = await my_response.extension.next_payload()
1210+
1211+
if message is None: # server just closed the connection. exit.
1212+
print("received goaway from server")
1213+
return
1214+
1215+
print(f"received message: '{message}'")
1216+
except ReadTimeout: # if no message received within 1s
1217+
pass
1218+
1219+
await asyncio.sleep(1) # let some time for the write part to acquire the lock
1220+
iteration_counter += 1
1221+
1222+
# send a ping every four iteration
1223+
if iteration_counter % 4 == 0:
1224+
await my_response.extension.ping()
1225+
print("ping sent")
1226+
1227+
async def main() -> None:
1228+
async with AsyncSession() as s:
1229+
resp = await s.get("wss://echo.websocket.org", timeout=1)
1230+
1231+
print(resp)
1232+
1233+
task = asyncio.create_task(read_from_ws(resp))
1234+
1235+
for i in range(30):
1236+
to_send = f"Hello World {i}"
1237+
await resp.extension.send_payload(to_send)
1238+
print(f"sent message: '{to_send}'")
1239+
await asyncio.sleep(1) # let some time for the read part to acquire the lock
1240+
1241+
# exit gently!
1242+
await resp.extension.close()
1243+
await task
1244+
1245+
1246+
if __name__ == "__main__":
1247+
asyncio.run(main())
1248+
1249+
1250+
.. note:: The given example are really basic ones. You may adjust at will the settings and algorithm to match your requisites.
1251+
11231252
-----------------------
11241253

11251254
Ready for more? Check out the :ref:`advanced <advanced>` section.

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ dynamic = ["version"]
4242
dependencies = [
4343
"charset_normalizer>=2,<4",
4444
"idna>=2.5,<4",
45-
"urllib3.future>=2.10.900,<3",
45+
"urllib3.future>=2.10.904,<3",
4646
"wassima>=1.0.1,<2",
4747
"kiss_headers>=2,<4",
4848
]

src/niquests/__version__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
__url__: str = "https://niquests.readthedocs.io"
1010

1111
__version__: str
12-
__version__ = "3.9.0"
12+
__version__ = "3.9.1"
1313

14-
__build__: int = 0x030900
14+
__build__: int = 0x030901
1515
__author__: str = "Kenneth Reitz"
1616
__author_email__: str = "me@kennethreitz.org"
1717
__license__: str = "Apache-2.0"

src/niquests/_async.py

+8
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ def __init__(
243243
AsyncHTTPAdapter(
244244
quic_cache_layer=self.quic_cache_layer,
245245
max_retries=retries,
246+
disable_http1=disable_http1,
246247
disable_http2=disable_http2,
247248
disable_http3=disable_http3,
248249
resolver=resolver,
@@ -258,6 +259,9 @@ def __init__(
258259
"http://",
259260
AsyncHTTPAdapter(
260261
max_retries=retries,
262+
disable_http1=disable_http1,
263+
disable_http2=disable_http2,
264+
disable_http3=disable_http3,
261265
resolver=resolver,
262266
source_address=source_address,
263267
disable_ipv4=disable_ipv4,
@@ -422,6 +426,7 @@ async def on_early_response(early_response: Response) -> None:
422426
AsyncHTTPAdapter(
423427
quic_cache_layer=self.quic_cache_layer,
424428
max_retries=self.retries,
429+
disable_http1=self._disable_http1,
425430
disable_http2=self._disable_http2,
426431
disable_http3=self._disable_http3,
427432
resolver=self.resolver,
@@ -437,6 +442,9 @@ async def on_early_response(early_response: Response) -> None:
437442
"http://",
438443
AsyncHTTPAdapter(
439444
max_retries=self.retries,
445+
disable_http1=self._disable_http1,
446+
disable_http2=self._disable_http2,
447+
disable_http3=self._disable_http3,
440448
resolver=self.resolver,
441449
source_address=self.source_address,
442450
disable_ipv4=self._disable_ipv4,

src/niquests/adapters.py

+13-2
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@
161161
_deepcopy_ci,
162162
parse_scheme,
163163
is_ocsp_capable,
164+
wrap_extension_for_http,
165+
async_wrap_extension_for_http,
164166
)
165167

166168
try:
@@ -911,7 +913,14 @@ def send(
911913
extension = None
912914

913915
if scheme is not None and scheme not in ("http", "https"):
914-
extension = load_extension(scheme)()
916+
if "+" in scheme:
917+
scheme, implementation = tuple(scheme.split("+", maxsplit=1))
918+
else:
919+
implementation = None
920+
921+
extension = wrap_extension_for_http(
922+
load_extension(scheme, implementation=implementation)
923+
)()
915924

916925
def early_response_hook(early_response: BaseHTTPResponse) -> None:
917926
nonlocal on_early_response
@@ -1916,7 +1925,9 @@ async def send(
19161925
else:
19171926
implementation = None
19181927

1919-
extension = async_load_extension(scheme, implementation=implementation)()
1928+
extension = async_wrap_extension_for_http(
1929+
async_load_extension(scheme, implementation=implementation)
1930+
)()
19201931

19211932
async def early_response_hook(early_response: BaseAsyncHTTPResponse) -> None:
19221933
nonlocal on_early_response

src/niquests/models.py

+37-10
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,14 @@
5454
from urllib3.fields import RequestField
5555
from urllib3.filepost import choose_boundary, encode_multipart_formdata
5656
from urllib3.util import parse_url
57-
from urllib3.contrib.webextensions._async import AsyncExtensionFromHTTP
58-
from urllib3.contrib.webextensions import ExtensionFromHTTP
57+
from urllib3.contrib.webextensions._async import (
58+
AsyncWebSocketExtensionFromHTTP,
59+
AsyncRawExtensionFromHTTP,
60+
)
61+
from urllib3.contrib.webextensions import (
62+
WebSocketExtensionFromHTTP,
63+
RawExtensionFromHTTP,
64+
)
5965
else:
6066
from urllib3_future import ( # type: ignore[assignment]
6167
BaseHTTPResponse,
@@ -73,8 +79,14 @@
7379
from urllib3_future.fields import RequestField # type: ignore[assignment]
7480
from urllib3_future.filepost import choose_boundary, encode_multipart_formdata # type: ignore[assignment]
7581
from urllib3_future.util import parse_url # type: ignore[assignment]
76-
from urllib3_future.contrib.webextensions._async import AsyncExtensionFromHTTP # type: ignore[assignment]
77-
from urllib3_future.contrib.webextensions import ExtensionFromHTTP # type: ignore[assignment]
82+
from urllib3_future.contrib.webextensions._async import ( # type: ignore[assignment]
83+
AsyncWebSocketExtensionFromHTTP,
84+
AsyncRawExtensionFromHTTP,
85+
)
86+
from urllib3_future.contrib.webextensions import ( # type: ignore[assignment]
87+
WebSocketExtensionFromHTTP,
88+
RawExtensionFromHTTP,
89+
)
7890

7991
from ._typing import (
8092
BodyFormType,
@@ -1023,10 +1035,21 @@ def __init__(self) -> None:
10231035
self.download_progress: TransferProgress | None = None
10241036

10251037
@property
1026-
def extension(self) -> ExtensionFromHTTP | None:
1027-
"""Access the I/O after an Upgraded connection. E.g. for a WebSocket handler."""
1038+
def extension(
1039+
self,
1040+
) -> (
1041+
WebSocketExtensionFromHTTP
1042+
| RawExtensionFromHTTP
1043+
| AsyncWebSocketExtensionFromHTTP
1044+
| AsyncRawExtensionFromHTTP
1045+
| None
1046+
):
1047+
"""Access the I/O after an Upgraded connection. E.g. for a WebSocket handler.
1048+
If the server opened a WebSocket, then the extension will be of type WebSocketExtensionFromHTTP.
1049+
Otherwise, on unknown protocol, it will be RawExtensionFromHTTP.
1050+
Warning: If you stand in an async inclosure, the type will be AsyncWebSocketExtensionFromHTTP or AsyncRawExtensionFromHTTP."""
10281051
return (
1029-
self.raw.extension
1052+
self.raw.extension # type: ignore[return-value]
10301053
if self.raw is not None and hasattr(self.raw, "extension")
10311054
else None
10321055
)
@@ -1612,10 +1635,14 @@ class AsyncResponse(Response):
16121635
}
16131636

16141637
@property
1615-
def extension(self) -> AsyncExtensionFromHTTP | None: # type: ignore[override]
1616-
"""Access the I/O after an Upgraded connection. E.g. for a WebSocket handler."""
1638+
def extension( # type: ignore[override]
1639+
self,
1640+
) -> AsyncWebSocketExtensionFromHTTP | AsyncRawExtensionFromHTTP | None:
1641+
"""Access the I/O after an Upgraded connection. E.g. for a WebSocket handler.
1642+
If the server opened a WebSocket, then the extension will be of type AsyncWebSocketExtensionFromHTTP.
1643+
Otherwise, on unknown protocol, it will be AsyncRawExtensionFromHTTP."""
16171644
return (
1618-
self.raw.extension
1645+
self.raw.extension # type: ignore[return-value]
16191646
if self.raw is not None and hasattr(self.raw, "extension")
16201647
else None
16211648
)

src/niquests/sessions.py

+2
Original file line numberDiff line numberDiff line change
@@ -1186,6 +1186,7 @@ def on_early_response(early_response) -> None:
11861186
max_retries=self.retries,
11871187
disable_http1=self._disable_http1,
11881188
disable_http2=self._disable_http2,
1189+
disable_http3=self._disable_http3,
11891190
resolver=self.resolver,
11901191
source_address=self.source_address,
11911192
disable_ipv4=self._disable_ipv4,
@@ -1222,6 +1223,7 @@ def on_early_response(early_response) -> None:
12221223
del kwargs["multiplexed"]
12231224
del kwargs["on_upload_body"]
12241225
del kwargs["on_post_connection"]
1226+
del kwargs["on_early_response"]
12251227

12261228
r = adapter.send(request, **kwargs)
12271229

0 commit comments

Comments
 (0)