diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d05f5c1f17..d702570a86 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,4 +28,4 @@ repos: - id: mypy args: [--check-untyped-defs] exclude: 'tests/|noxfile.py' - additional_dependencies: ['charset_normalizer', 'urllib3.future>=2.12.900', 'wassima>=1.0.1', 'idna', 'kiss_headers'] + additional_dependencies: ['charset_normalizer', 'urllib3.future>=2.12.900', 'wassima>=1.0.1', 'idna', 'kiss_headers', 'qh3>=1.3'] diff --git a/HISTORY.md b/HISTORY.md index bc25e555e9..583062a142 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,20 @@ Release History =============== +3.12.0 (2025-01-01) +------------------- + +**Fixed** +- Restoring the state of `AsyncSession` through pickle. +- Typing definition for query parameter not accepting `None` as values. (#193) +- Overload incorrect definition for `AsyncSession::get`. (#192) + +**Added** +- Support for `PathLike` objects for `verify` parameter when passing a ca bundle path. (#194) +- Caching and restoring OCSP state through pickling `Session` or `AsyncSession`. +- Caching and restoring QUIC known compatible hosts through pickling `Session` or `AsyncSession`. +- Shortcut convenient access to `Retry` and `Timeout` configuration objects in top-level import. + 3.11.4 (2024-12-23) ------------------- diff --git a/src/niquests/__init__.py b/src/niquests/__init__.py index 87a3af7e66..c80e544644 100644 --- a/src/niquests/__init__.py +++ b/src/niquests/__init__.py @@ -49,8 +49,13 @@ if HAS_LEGACY_URLLIB3 is False: from urllib3.exceptions import DependencyWarning + from urllib3 import Timeout as TimeoutConfiguration, Retry as RetryConfiguration else: from urllib3_future.exceptions import DependencyWarning # type: ignore[assignment] + from urllib3_future import ( # type: ignore[assignment] + Timeout as TimeoutConfiguration, + Retry as RetryConfiguration, + ) # urllib3's DependencyWarnings should be silenced. warnings.simplefilter("ignore", DependencyWarning) @@ -128,4 +133,6 @@ "codes", "AsyncSession", "AsyncResponse", + "TimeoutConfiguration", + "RetryConfiguration", ) diff --git a/src/niquests/__version__.py b/src/niquests/__version__.py index 06392be4cd..24d0084a0a 100644 --- a/src/niquests/__version__.py +++ b/src/niquests/__version__.py @@ -9,9 +9,9 @@ __url__: str = "https://niquests.readthedocs.io" __version__: str -__version__ = "3.11.4" +__version__ = "3.12.0" -__build__: int = 0x031104 +__build__: int = 0x031200 __author__: str = "Kenneth Reitz" __author_email__: str = "me@kennethreitz.org" __license__: str = "Apache-2.0" diff --git a/src/niquests/_async.py b/src/niquests/_async.py index 54b1d8d1cf..d7733da444 100644 --- a/src/niquests/_async.py +++ b/src/niquests/_async.py @@ -301,6 +301,52 @@ async def __aenter__(self) -> AsyncSession: async def __aexit__(self, exc, value, tb) -> None: await self.close() + def __setstate__(self, state): + for attr, value in state.items(): + setattr(self, attr, value) + + self.resolver = create_async_resolver(None) + self._own_resolver = True + + self.adapters = OrderedDict() + self.mount( + "https://", + AsyncHTTPAdapter( + quic_cache_layer=self.quic_cache_layer, + max_retries=self.retries, + disable_http1=self._disable_http1, + disable_http2=self._disable_http2, + disable_http3=self._disable_http3, + source_address=self.source_address, + disable_ipv4=self._disable_ipv4, + disable_ipv6=self._disable_ipv6, + resolver=self.resolver, + pool_connections=self._pool_connections, + pool_maxsize=self._pool_maxsize, + happy_eyeballs=self._happy_eyeballs, + keepalive_delay=self._keepalive_delay, + keepalive_idle_window=self._keepalive_idle_window, + ), + ) + self.mount( + "http://", + AsyncHTTPAdapter( + max_retries=self.retries, + disable_http1=self._disable_http1, + disable_http2=self._disable_http2, + disable_http3=self._disable_http3, + source_address=self.source_address, + disable_ipv4=self._disable_ipv4, + disable_ipv6=self._disable_ipv6, + resolver=self.resolver, + pool_connections=self._pool_connections, + pool_maxsize=self._pool_maxsize, + happy_eyeballs=self._happy_eyeballs, + keepalive_delay=self._keepalive_delay, + keepalive_idle_window=self._keepalive_idle_window, + ), + ) + def mount(self, prefix: str, adapter: AsyncBaseAdapter) -> None: # type: ignore[override] super().mount(prefix, adapter) # type: ignore[arg-type] diff --git a/src/niquests/extensions/_async_ocsp.py b/src/niquests/extensions/_async_ocsp.py index 0d696c2f4b..738ae436b4 100644 --- a/src/niquests/extensions/_async_ocsp.py +++ b/src/niquests/extensions/_async_ocsp.py @@ -194,6 +194,43 @@ def __init__(self, max_size: int = 2048): self._timings: list[datetime.datetime] = [] self.hold: bool = False + @staticmethod + def support_pickle() -> bool: + """This gives you a hint on whether you can cache it to restore later.""" + return hasattr(OCSPResponse, "serialize") + + def __getstate__(self) -> dict[str, typing.Any]: + return { + "_max_size": self._max_size, + "_store": {k: v.serialize() for k, v in self._store.items()}, + "_issuers_map": {k: v.serialize() for k, v in self._issuers_map.items()}, + } + + def __setstate__(self, state: dict[str, typing.Any]) -> None: + if ( + "_store" not in state + or "_issuers_map" not in state + or "_max_size" not in state + ): + raise OSError("unrecoverable state for InMemoryRevocationStatus") + + self.hold = False + self._timings = [] + + self._max_size = state["_max_size"] + + self._store = {} + self._semaphores = {} + + for k, v in state["_store"].items(): + self._store[k] = OCSPResponse.deserialize(v) + self._semaphores[k] = asyncio.Semaphore() + + self._issuers_map = {} + + for k, v in state["_issuers_map"].items(): + self._issuers_map[k] = Certificate.deserialize(v) + def get_issuer_of(self, peer_certificate: Certificate) -> Certificate | None: fingerprint: str = _str_fingerprint_of(peer_certificate) diff --git a/src/niquests/extensions/_ocsp.py b/src/niquests/extensions/_ocsp.py index 6d069b93af..535992a64f 100644 --- a/src/niquests/extensions/_ocsp.py +++ b/src/niquests/extensions/_ocsp.py @@ -5,6 +5,7 @@ import socket import ssl import threading +import typing import warnings from hashlib import sha256 from random import randint @@ -204,6 +205,42 @@ def __init__(self, max_size: int = 2048): self._access_lock = threading.RLock() self.hold: bool = False + @staticmethod + def support_pickle() -> bool: + """This gives you a hint on whether you can cache it to restore later.""" + return hasattr(OCSPResponse, "serialize") + + def __getstate__(self) -> dict[str, typing.Any]: + return { + "_max_size": self._max_size, + "_store": {k: v.serialize() for k, v in self._store.items()}, + "_issuers_map": {k: v.serialize() for k, v in self._issuers_map.items()}, + } + + def __setstate__(self, state: dict[str, typing.Any]) -> None: + if ( + "_store" not in state + or "_issuers_map" not in state + or "_max_size" not in state + ): + raise OSError("unrecoverable state for InMemoryRevocationStatus") + + self._access_lock = threading.RLock() + self.hold = False + self._timings = [] + + self._max_size = state["_max_size"] + + self._store = {} + + for k, v in state["_store"].items(): + self._store[k] = OCSPResponse.deserialize(v) + + self._issuers_map = {} + + for k, v in state["_issuers_map"].items(): + self._issuers_map[k] = Certificate.deserialize(v) + def get_issuer_of(self, peer_certificate: Certificate) -> Certificate | None: with self._access_lock: fingerprint: str = _str_fingerprint_of(peer_certificate) diff --git a/src/niquests/extensions/_picotls.py b/src/niquests/extensions/_picotls.py index 99a5a0cafc..a709003a18 100644 --- a/src/niquests/extensions/_picotls.py +++ b/src/niquests/extensions/_picotls.py @@ -789,4 +789,5 @@ async def async_send_tls(s, rec_type, msg): "HANDSHAKE", "ALERT", "CHANGE_CIPHER", + "PicoTLSException", ) diff --git a/src/niquests/sessions.py b/src/niquests/sessions.py index 534e46c4cc..962a9a1dba 100644 --- a/src/niquests/sessions.py +++ b/src/niquests/sessions.py @@ -233,6 +233,7 @@ class Session: "_keepalive_delay", "_keepalive_idle_window", "base_url", + "quic_cache_layer", ] def __init__( @@ -1476,13 +1477,18 @@ def mount(self, prefix: str, adapter: BaseAdapter) -> None: def __getstate__(self): state = {attr: getattr(self, attr, None) for attr in self.__attrs__} + if ( + self._ocsp_cache is not None + and hasattr(self._ocsp_cache, "support_pickle") + and self._ocsp_cache.support_pickle() is True + ): + state["_ocsp_cache"] = self._ocsp_cache return state def __setstate__(self, state): for attr, value in state.items(): setattr(self, attr, value) - self.quic_cache_layer = QuicSharedCache(max_size=12_288) self.resolver = create_resolver(None) self._own_resolver = True diff --git a/src/niquests/structures.py b/src/niquests/structures.py index d29aed638f..4e00150626 100644 --- a/src/niquests/structures.py +++ b/src/niquests/structures.py @@ -185,6 +185,14 @@ def __init__(self, max_size: int | None) -> None: self._max_size = max_size self._lock: threading.RLock | DummyLock = threading.RLock() + def __getstate__(self) -> dict[str, typing.Any]: + return {"_store": self._store, "_max_size": self._max_size} + + def __setstate__(self, state: dict[str, typing.Any]) -> None: + self._lock = threading.RLock() + self._store = state["_store"] + self._max_size = state["_max_size"] + def __delitem__(self, __key) -> None: with self._lock: del self._store[__key] @@ -248,6 +256,11 @@ def __init__(self, max_size: int | None) -> None: super().__init__(max_size) self._lock = DummyLock() + def __setstate__(self, state: dict[str, typing.Any]) -> None: + self._lock = DummyLock() + self._store = state["_store"] + self._max_size = state["_max_size"] + class DummyLock: def __enter__(self):