From 586d8e6feaa20ac51ad25697998725fe923e4b03 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Thu, 1 Oct 2026 21:30:14 +0300 Subject: [PATCH] feat: make type checkers reject unknown client options The option TypedDicts behind **httpx2_options are now closed (PEP 728) via typing_extensions, so ty reports a misspelled or refused option such as verfy= or cert= at type-check time; pyright and mypy already did. --- pyproject.toml | 3 +++ src/httpware/client.py | 7 ++++--- tests/test_client_options.py | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 767fd09..ed9fc62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,9 @@ classifiers = [ version = "0" dependencies = [ "httpx2>=2.0.0,<3.0", + # 4.14.0 is the first whose closed TypedDicts keep their keys on Python 3.14, and the + # cp314 pydantic floor (2.12) needs 4.14.1. + "typing-extensions>=4.14.1", ] [project.optional-dependencies] diff --git a/src/httpware/client.py b/src/httpware/client.py index 807070a..d7bc077 100644 --- a/src/httpware/client.py +++ b/src/httpware/client.py @@ -7,6 +7,7 @@ from http import HTTPStatus import httpx2 +import typing_extensions from httpware._internal import import_checker from httpware._internal.body_cap import _read_capped, _read_capped_async, _validate_max_response_body_bytes @@ -75,7 +76,7 @@ def _reject_base_url_query(base_url: httpx2.URL | str) -> None: raise ValueError(_BASE_URL_QUERY_MESSAGE) -class _ClientOptionsBase(typing.TypedDict, total=False): +class _ClientOptionsBase(typing_extensions.TypedDict, total=False): base_url: str headers: dict[str, str] | None params: dict[str, str] | None @@ -93,14 +94,14 @@ class _ClientOptionsBase(typing.TypedDict, total=False): default_encoding: str | Callable[[bytes], str | None] -class _AsyncClientOptions(_ClientOptionsBase, total=False): +class _AsyncClientOptions(_ClientOptionsBase, total=False, closed=True): """Keyword arguments `AsyncClient` forwards to the `httpx2.AsyncClient` it owns.""" transport: httpx2.AsyncBaseTransport | None mounts: Mapping[str, httpx2.AsyncBaseTransport | None] | None -class _ClientOptions(_ClientOptionsBase, total=False): +class _ClientOptions(_ClientOptionsBase, total=False, closed=True): """Keyword arguments `Client` forwards to the `httpx2.Client` it owns.""" transport: httpx2.BaseTransport | None diff --git a/tests/test_client_options.py b/tests/test_client_options.py index d93dc00..9507a1e 100644 --- a/tests/test_client_options.py +++ b/tests/test_client_options.py @@ -110,6 +110,20 @@ def test_unsupported_option_is_typeerror(client_cls: type, key: str) -> None: client_cls(**{key: object()}) +def test_type_checkers_reject_unsupported_async_options() -> None: + with pytest.raises(TypeError, match="verfy"): + AsyncClient(verfy=True) # ty: ignore[unknown-argument] + with pytest.raises(TypeError, match="cert"): + AsyncClient(cert="client.pem") # ty: ignore[unknown-argument] + + +def test_type_checkers_reject_unsupported_sync_options() -> None: + with pytest.raises(TypeError, match="verfy"): + Client(verfy=True) # ty: ignore[unknown-argument] + with pytest.raises(TypeError, match="cert"): + Client(cert="client.pem") # ty: ignore[unknown-argument] + + @pytest.mark.parametrize("client_cls", [AsyncClient, Client]) def test_follow_redirects_with_body_cap_is_valueerror(client_cls: type) -> None: with pytest.raises(ValueError, match="follow_redirects"):