diff --git a/Cargo.lock b/Cargo.lock index 96b6bd03..ee8110ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,6 +415,32 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive-deftly" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "724949bdda5310709ca2f97bf69c2b01e3362f44fef1fb47913445db4c1debb5" +dependencies = [ + "derive-deftly-macros", +] + +[[package]] +name = "derive-deftly-macros" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17933e749f2502cfcd1c27519292fbbea22e8fc64ce680e863f855e8cd8cce23" +dependencies = [ + "indexmap", + "itertools", + "proc-macro-crate", + "proc-macro2", + "quote", + "strum", + "syn 3.0.5", + "unicode-ident", + "void", +] + [[package]] name = "digest" version = "0.10.7" @@ -946,6 +972,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1346,6 +1381,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1758,6 +1802,27 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "syn" version = "2.0.119" @@ -2033,6 +2098,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.3" @@ -2201,6 +2296,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "walkdir" version = "2.5.0" @@ -2478,6 +2579,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -2535,6 +2645,7 @@ version = "0.13.0" dependencies = [ "bytes", "cookie", + "derive-deftly", "futures-util", "hickory-resolver", "http", diff --git a/Cargo.toml b/Cargo.toml index 10f0902b..a7c5fe1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ abi3-py313 = ["pyo3/abi3-py313"] abi3-py314 = ["pyo3/abi3-py314"] [dependencies] +derive-deftly = { version = "1.14.0", default-features = false, features = ["minimal-1"] } pingora-runtime = "0.9.0" tokio = { version = "1.53.2", features = ["rt-multi-thread", "sync", "time", "net", "macros"] } tokio-util = { version = "0.7.18", features = ["rt"] } diff --git a/RELEASE.md b/RELEASE.md index 43c5515c..8604b96b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,3 +1,11 @@ +## [unreleased] + +### Compatibility Notes + +- Unknown keyword arguments now raise `TypeError` when calling async or blocking client constructors, HTTP or WebSocket request methods and functions, `Http1Options`, `Http2Options`, `TlsOptions`, or `Proxy` factories. These arguments were previously ignored. +- Set client-only options such as `connect_timeout` and `proxies` on `Client`; passing them to a request now raises `TypeError`. Use `proxy` to select a proxy for one request. +- Async request calls check keyword names before returning a coroutine. Values for supported request keywords are still converted on first `await` or entry with `async with`. + ## [0.11.0](https://github.com/0x676e67/wreq-python/compare/v0.10.2..v0.11.0) - 2026-04-13 ### Features diff --git a/docs/source/guide/basic.md b/docs/source/guide/basic.md index 82caf9f8..cf03e13b 100644 --- a/docs/source/guide/basic.md +++ b/docs/source/guide/basic.md @@ -67,6 +67,18 @@ async with client.request( `query` and `form` accept dictionaries or lists of pairs. Pairs preserve repeated keys. Values can be strings, integers, floats or booleans. +Client constructors and HTTP or WebSocket request methods reject unknown +keyword arguments with `TypeError` when called, in both the async and blocking +APIs. The same rule applies to module-level request functions, `Http1Options`, +`Http2Options`, `TlsOptions`, and the +`Proxy.http()`, `Proxy.https()`, `Proxy.all()` and `Proxy.unix()` factories. +Set client-only options, such as `connect_timeout`, on `Client`. + +Client and protocol option values are converted at construction, and proxy +option values during the factory call. Values for supported async request +keywords are converted when the coroutine is first awaited or entered with +`async with`; blocking requests convert them during the method or function call. + ## Request bodies Use `json` for JSON, `form` for URL-encoded fields, and `body` for raw text or diff --git a/python/wreq/blocking.py b/python/wreq/blocking.py index e44cefc9..889451ae 100644 --- a/python/wreq/blocking.py +++ b/python/wreq/blocking.py @@ -216,6 +216,9 @@ def __str__(self) -> str: ... class Client: r""" A blocking client for making HTTP requests. + + Unknown configuration or request keyword arguments raise `TypeError` + during the call. """ runtime: Runtime diff --git a/python/wreq/http1.py b/python/wreq/http1.py index b860436f..3cf3f1da 100644 --- a/python/wreq/http1.py +++ b/python/wreq/http1.py @@ -10,6 +10,8 @@ class Params(TypedDict): """ All parameters for HTTP/1 connections. + + Unknown keyword arguments raise `TypeError` when constructing `Http1Options`. """ http09_responses: NotRequired[bool] diff --git a/python/wreq/http2.py b/python/wreq/http2.py index 6d04529a..465f73c0 100644 --- a/python/wreq/http2.py +++ b/python/wreq/http2.py @@ -268,6 +268,8 @@ def __str__(self) -> str: class Params(TypedDict): """ All parameters for HTTP/2 connections. + + Unknown keyword arguments raise `TypeError` when constructing `Http2Options`. """ initial_window_size: NotRequired[int] diff --git a/python/wreq/proxy.py b/python/wreq/proxy.py index 2f7e03d7..e6b479c3 100644 --- a/python/wreq/proxy.py +++ b/python/wreq/proxy.py @@ -27,6 +27,8 @@ class Proxy: r""" A proxy server for a request. Supports HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, and SOCKS5h protocols. + + Unknown keyword arguments passed to its factory methods raise `TypeError`. """ @staticmethod diff --git a/python/wreq/tls.py b/python/wreq/tls.py index 20b56353..c15901c1 100644 --- a/python/wreq/tls.py +++ b/python/wreq/tls.py @@ -254,6 +254,8 @@ def file(path: Path | str) -> "KeyLog": class Params(TypedDict): """ All parameters for TLS connections. + + Unknown keyword arguments raise `TypeError` when constructing `TlsOptions`. """ alpn_protocols: NotRequired[Sequence[AlpnProtocol]] diff --git a/python/wreq/wreq.py b/python/wreq/wreq.py index 2466c5f7..72ac5483 100644 --- a/python/wreq/wreq.py +++ b/python/wreq/wreq.py @@ -539,6 +539,11 @@ def __str__(self) -> str: ... class ClientConfig(TypedDict): + """Keyword options for constructing async and blocking clients. + + Unknown keys raise `TypeError` when passed to a `Client` constructor. + """ + runtime: NotRequired[Runtime | None] """Runtime for this client and its responses; None uses the shared default. Async clients reject a `CURRENT_THREAD` runtime with `ValueError`.""" @@ -819,6 +824,12 @@ class ClientConfig(TypedDict): class Request(TypedDict): + """Keyword options for HTTP request methods and module-level functions. + + Unknown keys raise `TypeError` when the method or function is called. Set + client-only options, such as `connect_timeout`, on `Client`. + """ + emulation: NotRequired[emulation.Emulation | emulation.Profile] """ The Emulation settings for the request. @@ -987,6 +998,11 @@ class Request(TypedDict): class WebSocketRequest(TypedDict): + """Keyword options for WebSocket request methods and module-level functions. + + Unknown keys raise `TypeError` when the method or function is called. + """ + emulation: NotRequired[emulation.Emulation | emulation.Profile] """ The Emulation settings for the request. @@ -1143,6 +1159,10 @@ async def __aexit__(self, _exc_type: Any, _exc_value: Any, _traceback: Any) -> N class Client: r""" A client for making HTTP requests. + + Unknown client or request keyword arguments raise `TypeError` during the + call. Values for supported request keywords are converted when the coroutine + is first awaited or entered with `async with`. """ runtime: Runtime diff --git a/src/client.rs b/src/client.rs index 1ece1bad..0b9ce475 100644 --- a/src/client.rs +++ b/src/client.rs @@ -10,6 +10,7 @@ use std::{ time::Duration, }; +use derive_deftly::Deftly; use pyo3::{ IntoPyObjectExt, exceptions::PyValueError, prelude::*, pybacked::PyBackedStr, types::PyDict, }; @@ -32,6 +33,7 @@ use crate::{ http::Method, http1::Http1Options, http2::Http2Options, + macros::reject_unknown_kwargs, proxy::Proxy, redirect, runtime, tls::{Identity, KeyLog, TlsOptions, TlsVerify, TlsVersion}, @@ -58,180 +60,155 @@ impl SocketAddr { impl_print_str!(Display, SocketAddr); /// A builder for `Client`. -#[derive(Default)] +#[derive(Default, Deftly)] +#[derive_deftly(ExtractOptions)] +#[deftly(deny_unknown)] struct Builder { runtime: Option, + /// The Emulation settings for the client. emulation: Option, + /// The user agent to use for the client. user_agent: Option, + /// The headers to use for the client. headers: Option, + /// The original headers to use for the client. orig_headers: Option, + /// Whether to use referer. referer: Option, + /// Whether to redirect policy. redirect: Option, + /// Whether to raise for status. raise_for_status: Option, // ========= Cookie options ========= /// Whether to use cookie store. cookie_store: Option, + /// Whether to use cookie store provider. cookie_provider: Option, // ========= Timeout options ========= /// The timeout to use for the client. timeout: Option, + /// The connect timeout to use for the client. connect_timeout: Option, + /// The read timeout to use for the client. read_timeout: Option, // ========= TCP options ========= /// Set that all sockets have `SO_KEEPALIVE` set with the supplied duration. tcp_keepalive: Option, + /// Set the interval between TCP keepalive probes. tcp_keepalive_interval: Option, + /// Set the number of retries for TCP keepalive. tcp_keepalive_retries: Option, + /// Set an optional user timeout for TCP sockets. tcp_user_timeout: Option, + /// Set that all sockets have `NO_DELAY` set. tcp_nodelay: Option, + /// Set that all sockets have `SO_REUSEADDR` set. tcp_reuse_address: Option, // ========= Connection pool options ========= /// Set an optional timeout for idle sockets being kept-alive. pool_idle_timeout: Option, + /// Sets the maximum idle connection per host allowed in the pool. pool_max_idle_per_host: Option, + /// Sets the maximum number of connections in the pool. pool_max_size: Option, + // ========= Network options ========= + /// Whether to disable the proxy for the client. + no_proxy: Option, + + /// The proxies to use for the client. + proxies: Option>, + + /// Bind to a local IP Address. + local_address: Option, + + /// Bind to local IP Addresses (IPv4, IPv6). + local_addresses: Option, Option)>>, + + /// Bind to an interface by `SO_BINDTODEVICE`. + interface: Option, + + /// Whether to use HTTPS only. + https_only: Option, + // ========= Protocol options ========= /// Whether to use the HTTP/1 protocol only. http1_only: Option, + /// Whether to use the HTTP/2 protocol only. http2_only: Option, - /// Whether to use HTTPS only. - https_only: Option, + /// Sets the HTTP/1 options for the client. http1_options: Option, + /// sets the HTTP/2 options for the client. http2_options: Option, // ========= TLS options ========= /// Whether to verify the SSL certificate or root certificate file path. tls_verify: Option, + /// Whether to verify the hostname in the SSL certificate. tls_verify_hostname: Option, + /// Represents a private key and X509 cert as a client certificate. tls_identity: Option, + /// Key logging policy for TLS session keys. tls_keylog: Option, + /// Add TLS information as `TlsInfo` extension to responses. tls_info: Option, + /// The minimum TLS version to use for the client. tls_min_version: Option, + /// The maximum TLS version to use for the client. tls_max_version: Option, + /// Sets the TLS options for the client. tls_options: Option, - // ========= Network options ========= - /// Whether to disable the proxy for the client. - no_proxy: Option, - /// The proxies to use for the client. - proxies: Option>, - /// Bind to a local IP Address. - local_address: Option, - /// Bind to local IP Addresses (IPv4, IPv6). - local_addresses: Option, Option)>>, - /// Bind to an interface by `SO_BINDTODEVICE`. - interface: Option, - // ========= DNS options ========= dns_options: Option, // ========= Compression options ========= /// Sets gzip as an accepted encoding. gzip: Option, + /// Sets brotli as an accepted encoding. brotli: Option, + /// Sets deflate as an accepted encoding. deflate: Option, + /// Sets zstd as an accepted encoding. zstd: Option, } -impl FromPyObject<'_, '_> for Builder { - type Error = PyErr; - - fn extract(ob: Borrowed) -> PyResult { - let mut builder = Self::default(); - extract_option!(ob, builder, runtime); - extract_option!(ob, builder, emulation); - extract_option!(ob, builder, user_agent); - extract_option!(ob, builder, headers); - extract_option!(ob, builder, orig_headers); - extract_option!(ob, builder, referer); - extract_option!(ob, builder, redirect); - extract_option!(ob, builder, raise_for_status); - - extract_option!(ob, builder, cookie_store); - extract_option!(ob, builder, cookie_provider); - - extract_option!(ob, builder, timeout); - extract_option!(ob, builder, connect_timeout); - extract_option!(ob, builder, read_timeout); - - extract_option!(ob, builder, tcp_keepalive); - extract_option!(ob, builder, tcp_keepalive_interval); - extract_option!(ob, builder, tcp_keepalive_retries); - extract_option!(ob, builder, tcp_user_timeout); - extract_option!(ob, builder, tcp_nodelay); - extract_option!(ob, builder, tcp_reuse_address); - - extract_option!(ob, builder, pool_idle_timeout); - extract_option!(ob, builder, pool_max_idle_per_host); - extract_option!(ob, builder, pool_max_size); - - extract_option!(ob, builder, no_proxy); - extract_option!(ob, builder, proxies); - extract_option!(ob, builder, local_address); - extract_option!(ob, builder, local_addresses); - extract_option!(ob, builder, interface); - - extract_option!(ob, builder, https_only); - extract_option!(ob, builder, http1_only); - extract_option!(ob, builder, http2_only); - extract_option!(ob, builder, http1_options); - extract_option!(ob, builder, http2_options); - - extract_option!(ob, builder, tls_verify); - extract_option!(ob, builder, tls_verify_hostname); - extract_option!(ob, builder, tls_identity); - extract_option!(ob, builder, tls_keylog); - extract_option!(ob, builder, tls_info); - extract_option!(ob, builder, tls_min_version); - extract_option!(ob, builder, tls_max_version); - extract_option!(ob, builder, tls_options); - - extract_option!(ob, builder, dns_options); - - extract_option!(ob, builder, gzip); - extract_option!(ob, builder, brotli); - extract_option!(ob, builder, deflate); - extract_option!(ob, builder, zstd); - Ok(builder) - } -} - /// A client for making HTTP requests. +/// Unknown keyword arguments raise `TypeError` at call time. #[derive(Clone)] #[pyclass(subclass, frozen, skip_from_py_object)] pub struct Client { @@ -249,6 +226,7 @@ pub struct Client { } /// A blocking client for making HTTP requests. +/// Unknown keyword arguments raise `TypeError` when the function is called. #[derive(Default)] #[pyclass(name = "Client", subclass, frozen, skip_from_py_object)] pub struct BlockingClient(Client); @@ -265,7 +243,7 @@ impl Client { url: PyBackedStr, kwds: Option>, ) -> PyResult> { - let kwds = kwds.map(Bound::unbind); + let kwds = reject_unknown_kwargs(kwds, Request::FIELDS)?.map(Bound::unbind); coroutine::managed(py, qualname, self.clone().execute(method, url, kwds)) } @@ -659,6 +637,7 @@ impl Client { } /// Make a request with the given method and URL. + /// Unknown keywords raise `TypeError` when this method is called. #[pyo3(signature = (method, url, **kwds))] pub fn request<'py>( &self, @@ -671,6 +650,7 @@ impl Client { } /// Make a WebSocket request to the given URL. + /// Unknown keywords raise `TypeError` when this method is called. #[pyo3(signature = (url, **kwds))] pub fn websocket<'py>( &self, @@ -678,7 +658,7 @@ impl Client { url: PyBackedStr, kwds: Option>, ) -> PyResult> { - let kwds = kwds.map(Bound::unbind); + let kwds = reject_unknown_kwargs(kwds, WebSocketRequest::FIELDS)?.map(Bound::unbind); coroutine::managed(py, "Client.websocket", self.clone().connect(url, kwds)) } } @@ -823,6 +803,7 @@ impl BlockingClient { } /// Make a request with the specified method and URL. + /// Unknown keywords raise `TypeError` when this method is called. #[pyo3(signature = (method, url, **kwds))] pub fn request( &self, @@ -838,6 +819,7 @@ impl BlockingClient { } /// Make a WebSocket request to the specified URL. + /// Unknown keywords raise `TypeError` when this method is called. #[pyo3(signature = (url, **kwds))] pub fn websocket( &self, diff --git a/src/client/req.rs b/src/client/req.rs index a39f3bec..6ff23d19 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -5,9 +5,10 @@ use std::{ time::Duration, }; +use derive_deftly::Deftly; use futures_util::TryFutureExt; use http::header::COOKIE; -use pyo3::{PyResult, exceptions::asyncio::CancelledError, prelude::*, pybacked::PyBackedStr}; +use pyo3::{PyResult, exceptions::asyncio::CancelledError, pybacked::PyBackedStr}; use tokio_util::sync::CancellationToken; use crate::{ @@ -28,9 +29,41 @@ use crate::{ }; /// The parameters for a request. -#[derive(Default)] +#[derive(Default, Deftly)] +#[derive_deftly(ExtractOptions)] +#[deftly(deny_unknown)] #[non_exhaustive] pub struct Request { + /// The JSON body to use for the request. + json: Option, + + /// The form parameters to use for the request. + form: Option
, + + /// The query parameters to use for the request. + query: Option, + + /// The headers to use for the request. + headers: Option, + + /// The timeout to use for the request. + timeout: Option, + + /// The read timeout to use for the request. + read_timeout: Option, + + /// The cookies to use for the request. + cookies: Option, + + /// The authentication to use for the request. + auth: Option, + + /// The bearer authentication to use for the request. + bearer_auth: Option, + + /// The basic authentication to use for the request. + basic_auth: Option<(PyBackedStr, Option)>, + /// The Emulation settings for the request. emulation: Option, @@ -46,27 +79,15 @@ pub struct Request { /// Bind to an interface by `SO_BINDTODEVICE`. interface: Option, - /// The timeout to use for the request. - timeout: Option, - - /// The read timeout to use for the request. - read_timeout: Option, - /// The HTTP version to use for the request. version: Option, - /// The headers to use for the request. - headers: Option, - /// The original headers to use for the request. orig_headers: Option, /// The option enables default headers. default_headers: Option, - /// The cookies to use for the request. - cookies: Option, - /// The redirect policy to use for the request. redirect: Option, @@ -85,33 +106,19 @@ pub struct Request { /// Sets zstd as an accepted encoding. zstd: Option, - /// The authentication to use for the request. - auth: Option, - - /// The bearer authentication to use for the request. - bearer_auth: Option, - - /// The basic authentication to use for the request. - basic_auth: Option<(PyBackedStr, Option)>, - - /// The query parameters to use for the request. - query: Option, - - /// The form parameters to use for the request. - form: Option, - - /// The JSON body to use for the request. - json: Option, + /// The body to use for the request. + #[deftly(extract_last)] + body: Option, /// The multipart form to use for the request. + #[deftly(extract_last)] multipart: Option, - - /// The body to use for the request. - body: Option, } /// The parameters for a WebSocket request. -#[derive(Default)] +#[derive(Default, Deftly)] +#[derive_deftly(ExtractOptions)] +#[deftly(deny_unknown)] #[non_exhaustive] pub struct WebSocketRequest { /// The Emulation settings for the request. @@ -129,6 +136,9 @@ pub struct WebSocketRequest { /// Bind to an interface by `SO_BINDTODEVICE`. interface: Option, + /// The HTTP version to use for the request. + version: Option, + /// The headers to use for the request. headers: Option, @@ -144,9 +154,6 @@ pub struct WebSocketRequest { /// The protocols to use for the request. protocols: Option>, - /// The HTTP version to use for the request. - version: Option, - /// The authentication to use for the request. auth: Option, @@ -182,84 +189,6 @@ pub struct WebSocketRequest { accept_unmasked_frames: Option, } -// ===== impl Request ===== - -impl FromPyObject<'_, '_> for Request { - type Error = PyErr; - - fn extract(ob: Borrowed) -> PyResult { - let mut request = Self::default(); - // Common keys first. A body or multipart form can start a generator or take stream - // parts, so they are extracted last, after every other option has been validated. - extract_options!( - ob, - request, - [ - json, - form, - query, - headers, - timeout, - read_timeout, - cookies, - auth, - bearer_auth, - basic_auth, - emulation, - proxy, - local_address, - local_addresses, - interface, - version, - orig_headers, - default_headers, - redirect, - cookie_provider, - gzip, - brotli, - deflate, - zstd, - ], - [body, multipart] - ); - Ok(request) - } -} - -// ===== impl WebSocketRequest ===== - -impl FromPyObject<'_, '_> for WebSocketRequest { - type Error = PyErr; - - fn extract(ob: Borrowed) -> PyResult { - let mut params = Self::default(); - extract_option!(ob, params, emulation); - extract_option!(ob, params, proxy); - extract_option!(ob, params, local_address); - extract_option!(ob, params, local_addresses); - extract_option!(ob, params, interface); - - extract_option!(ob, params, version); - extract_option!(ob, params, headers); - extract_option!(ob, params, orig_headers); - extract_option!(ob, params, default_headers); - extract_option!(ob, params, cookies); - extract_option!(ob, params, protocols); - extract_option!(ob, params, auth); - extract_option!(ob, params, bearer_auth); - extract_option!(ob, params, basic_auth); - extract_option!(ob, params, query); - - extract_option!(ob, params, read_buffer_size); - extract_option!(ob, params, write_buffer_size); - extract_option!(ob, params, max_write_buffer_size); - extract_option!(ob, params, max_message_size); - extract_option!(ob, params, max_frame_size); - extract_option!(ob, params, accept_unmasked_frames); - Ok(params) - } -} - /// Build and send a request with `client`, failing with `CancelledError` once the client is /// closed. Callers run it on the client's runtime. pub async fn execute_request( diff --git a/src/http1.rs b/src/http1.rs index cf84ab12..cb817fd0 100644 --- a/src/http1.rs +++ b/src/http1.rs @@ -1,7 +1,10 @@ +use derive_deftly::Deftly; use pyo3::prelude::*; /// A builder for [`Http1Options`]. -#[derive(Default)] +#[derive(Default, Deftly)] +#[derive_deftly(ExtractOptions)] +#[deftly(deny_unknown)] struct Builder { /// Enable support for HTTP/0.9 responses. http09_responses: Option, @@ -28,33 +31,12 @@ struct Builder { allow_obsolete_multiline_headers_in_responses: Option, } -/// HTTP/1 protocol options for customizing connection behavior. -/// -/// These options allow you to customize the behavior of HTTP/1 connections, -/// such as enabling support for HTTP/0.9 responses, header case preservation, etc. +/// HTTP/1 options for connection behavior and response parsing. +/// Unknown keyword arguments raise `TypeError`. #[derive(Clone)] #[pyclass(frozen, str, from_py_object)] pub struct Http1Options(pub wreq::http1::Http1Options); -// ===== impl Builder ===== - -impl FromPyObject<'_, '_> for Builder { - type Error = PyErr; - - fn extract(ob: Borrowed) -> PyResult { - let mut params = Self::default(); - extract_option!(ob, params, http09_responses); - extract_option!(ob, params, writev); - extract_option!(ob, params, max_headers); - extract_option!(ob, params, read_buf_exact_size); - extract_option!(ob, params, max_buf_size); - extract_option!(ob, params, allow_spaces_after_header_name_in_responses); - extract_option!(ob, params, ignore_invalid_headers_in_responses); - extract_option!(ob, params, allow_obsolete_multiline_headers_in_responses); - Ok(params) - } -} - // ===== impl Http1Options ===== #[pymethods] diff --git a/src/http2.rs b/src/http2.rs index 85af845b..86bc884d 100644 --- a/src/http2.rs +++ b/src/http2.rs @@ -1,5 +1,6 @@ use std::{fmt::Debug, time::Duration}; +use derive_deftly::Deftly; use pyo3::prelude::*; define_enum!( @@ -109,7 +110,9 @@ pub struct PseudoOrder(wreq::http2::PseudoOrder); pub struct SettingsOrder(wreq::http2::SettingsOrder); /// A builder for [`Http2Options`]. -#[derive(Default)] +#[derive(Default, Deftly)] +#[derive_deftly(ExtractOptions)] +#[deftly(deny_unknown)] struct Builder { /// The initial window size for HTTP/2 streams. initial_window_size: Option, @@ -178,10 +181,8 @@ struct Builder { priorities: Option, } -/// Configuration for an HTTP/2 connection. -/// -/// This struct defines various parameters to fine-tune the behavior of an HTTP/2 connection, -/// including stream management, window sizes, frame limits, and header config. +/// HTTP/2 options for streams, flow control, frame limits and headers. +/// Unknown keyword arguments raise `TypeError`. #[derive(Clone)] #[pyclass(frozen, str, from_py_object)] pub struct Http2Options(pub wreq::http2::Http2Options); @@ -294,39 +295,6 @@ impl SettingsOrder { impl_print_str!(Display, SettingsOrder); -// ===== impl Builder ===== - -impl FromPyObject<'_, '_> for Builder { - type Error = PyErr; - - fn extract(ob: Borrowed) -> PyResult { - let mut params = Self::default(); - extract_option!(ob, params, initial_window_size); - extract_option!(ob, params, initial_connection_window_size); - extract_option!(ob, params, initial_max_send_streams); - extract_option!(ob, params, initial_stream_id); - extract_option!(ob, params, adaptive_window); - extract_option!(ob, params, max_frame_size); - extract_option!(ob, params, max_header_list_size); - extract_option!(ob, params, header_table_size); - extract_option!(ob, params, max_concurrent_streams); - extract_option!(ob, params, keep_alive_interval); - extract_option!(ob, params, keep_alive_timeout); - extract_option!(ob, params, keep_alive_while_idle); - extract_option!(ob, params, enable_push); - extract_option!(ob, params, enable_connect_protocol); - extract_option!(ob, params, no_rfc7540_priorities); - extract_option!(ob, params, max_concurrent_reset_streams); - extract_option!(ob, params, max_send_buf_size); - extract_option!(ob, params, max_pending_accept_reset_streams); - extract_option!(ob, params, headers_stream_dependency); - extract_option!(ob, params, headers_pseudo_order); - extract_option!(ob, params, settings_order); - extract_option!(ob, params, priorities); - Ok(params) - } -} - // ===== impl Http2Options ===== #[pymethods] diff --git a/src/lib.rs b/src/lib.rs index 6ec5ee70..796ab7d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,9 +65,13 @@ mod r#async { use pyo3::{prelude::*, pybacked::PyBackedStr, types::PyDict}; use crate::{ - client::Client, + client::{ + Client, + req::{Request, WebSocketRequest}, + }, coroutine::{self, Coroutine}, http::Method, + macros::reject_unknown_kwargs, }; /// Make a GET request with the given parameters. @@ -159,6 +163,7 @@ mod r#async { } /// Make a request with the given parameters. + /// Unknown keywords raise `TypeError` when this function is called. #[pyfunction] #[pyo3(signature = (method, url, **kwds))] pub fn request<'py>( @@ -171,6 +176,7 @@ mod r#async { } /// Make a WebSocket connection with the given parameters. + /// Unknown keywords raise `TypeError` when this function is called. #[pyfunction] #[pyo3(signature = (url, **kwds))] pub fn websocket<'py>( @@ -178,14 +184,13 @@ mod r#async { url: PyBackedStr, kwds: Option>, ) -> PyResult> { - let kwds = kwds.map(Bound::unbind); + let kwds = reject_unknown_kwargs(kwds, WebSocketRequest::FIELDS)?.map(Bound::unbind); coroutine::managed(py, "websocket", async move { Client::default().connect(url, kwds).await }) } - /// Return a coroutine named `qualname` that sends a request with the default - /// client, created on first await so an unawaited call is inert. + /// Return a coroutine named `qualname`; the default client is created on first await. fn send<'py>( py: Python<'py>, qualname: &'static str, @@ -193,7 +198,7 @@ mod r#async { url: PyBackedStr, kwds: Option>, ) -> PyResult> { - let kwds = kwds.map(Bound::unbind); + let kwds = reject_unknown_kwargs(kwds, Request::FIELDS)?.map(Bound::unbind); coroutine::managed(py, qualname, async move { Client::default().execute(method, url, kwds).await }) @@ -285,6 +290,7 @@ mod blocking { } /// Make a request with the given parameters (blocking). + /// Unknown keywords raise `TypeError` when this function is called. #[pyfunction] #[pyo3(signature = (method, url, **kwds))] pub fn request( @@ -297,6 +303,7 @@ mod blocking { } /// Make a WebSocket connection with the given parameters (blocking). + /// Unknown keywords raise `TypeError` when this function is called. #[pyfunction] #[pyo3(signature = (url, **kwds))] pub fn websocket( diff --git a/src/macros.rs b/src/macros.rs index 2c2a0336..f8e2d486 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,8 +1,34 @@ use pyo3::{ + BoundObject, + exceptions::PyTypeError, prelude::*, types::{PyDict, PyString}, }; +/// Validate keyword names and return the original optional Python object. +pub(crate) fn reject_unknown_kwargs<'py, B>( + kwargs: Option, + allowed: &[&str], +) -> PyResult> +where + B: BoundObject<'py, PyDict>, +{ + kwargs + .map(|kwargs| { + for (key, _) in kwargs.as_borrowed().iter() { + let key = key.cast::()?; + if !key.to_str().is_ok_and(|name| allowed.contains(&name)) { + return Err(PyTypeError::new_err(format!( + "unexpected keyword argument '{}'", + key.to_string_lossy() + ))); + } + } + Ok(kwargs) + }) + .transpose() +} + /// Look up an optional keyword; a dict answers a missing key without raising KeyError. pub(crate) fn lookup<'py>( ob: &Bound<'py, PyAny>, @@ -14,45 +40,65 @@ pub(crate) fn lookup<'py>( } } -macro_rules! extract_option { - ($ob:expr, $params:expr, $field:ident) => { - if let Some(value) = - $crate::macros::lookup(&$ob, pyo3::intern!($ob.py(), stringify!($field))) - { - $params.$field = value.extract()?; - } - }; -} +derive_deftly::define_derive_deftly! { + /// Derive keyword names and extraction from the parameter fields. + /// Reject unknown keys when the type specifies `deny_unknown`. + /// Marked fields are converted last, after other parameters are validated. + ExtractOptions for struct: -/// Like [`extract_option!`] for several fields, but stop looking up keys once every key -/// of a dict has been found. `$last` fields are looked up first and extracted after the -/// rest. Unknown keys are never found, so they make the scan run to the end and stay ignored. -macro_rules! extract_options { - ($ob:expr, $params:expr, [$($field:ident),* $(,)?], [$($last:ident),* $(,)?]) => {{ - $( - let $last = - $crate::macros::lookup(&$ob, pyo3::intern!($ob.py(), stringify!($last))); - )* - // A dict's length bounds the keys left to find; other mappings are scanned in full. - let mut remaining = $ob - .cast::() - .map_or(usize::MAX, |dict| dict.len()) - $(.saturating_sub(usize::from($last.is_some())))*; - $( - if remaining > 0 - && let Some(value) = - $crate::macros::lookup(&$ob, pyo3::intern!($ob.py(), stringify!($field))) - { - $params.$field = value.extract()?; - remaining -= 1; - } - )* - $( - if let Some(value) = $last { - $params.$last = value.extract()?; - } - )* - }}; + impl<$tgens> $ttype where $twheres { + pub(crate) const FIELDS: &'static [&'static str] = &[ + ${for fields { ${concat $fname}, }} + ]; + } + + impl<'__extract_a, '__extract_py, $tgens> + pyo3::FromPyObject<'__extract_a, '__extract_py> for $ttype + where $twheres + { + type Error = pyo3::PyErr; + + fn extract(ob: pyo3::Borrowed<'__extract_a, '__extract_py, pyo3::PyAny>) -> pyo3::PyResult { + use pyo3::prelude::*; + ${if tmeta(deny_unknown) { + let _ = $crate::macros::reject_unknown_kwargs( + ob.cast::().ok(), + Self::FIELDS, + )?; + }} + let mut params = Self::default(); + ${for fields { + ${when fmeta(extract_last)} + let ${paste pending_ $fname} = + $crate::macros::lookup(&ob, pyo3::intern!(ob.py(), ${concat $fname})); + }} + let mut remaining = ob.cast::() + .map_or(Self::FIELDS.len(), |dict| dict.len()) + ${for fields { + ${when fmeta(extract_last)} + .saturating_sub(usize::from(${paste pending_ $fname}.is_some())) + }}; + ${for fields { + ${when not(fmeta(extract_last))} + if let Some(value) = (remaining > 0) + .then(|| $crate::macros::lookup(&ob, pyo3::intern!(ob.py(), ${concat $fname}))) + .flatten() + { + params.$fname = value.extract()?; + remaining -= 1; + } + }} + ${for fields { + ${when fmeta(extract_last)} + if let Some(value) = ${paste pending_ $fname} { + params.$fname = value.extract()?; + } + }} + // Avoid an unused-assignment warning after the last generated field. + let _ = remaining; + Ok(params) + } + } } macro_rules! apply_option { diff --git a/src/proxy.rs b/src/proxy.rs index 4208fef1..b1d7e740 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,10 +1,13 @@ +use derive_deftly::Deftly; use pyo3::{prelude::*, pybacked::PyBackedStr}; use wreq::header::HeaderValue; use crate::{error::Error, extractor::Text, header::HeaderMap}; /// A builder for `Proxy`. -#[derive(Default)] +#[derive(Default, Deftly)] +#[derive_deftly(ExtractOptions)] +#[deftly(deny_unknown)] struct Builder { // Optional username for proxy authentication. username: Option, @@ -24,26 +27,11 @@ struct Builder { /// A proxy server for a request. /// Supports HTTP, HTTPS, SOCKS4, SOCKS4a, SOCKS5, and SOCKS5h protocols. +/// Factory methods reject unknown keyword arguments with `TypeError`. #[derive(Clone)] #[pyclass(subclass, frozen, str, from_py_object)] pub struct Proxy(pub wreq::Proxy); -// ===== impl Builder ===== - -impl FromPyObject<'_, '_> for Builder { - type Error = PyErr; - - fn extract(ob: Borrowed) -> PyResult { - let mut builder = Self::default(); - extract_option!(ob, builder, username); - extract_option!(ob, builder, password); - extract_option!(ob, builder, custom_http_auth); - extract_option!(ob, builder, custom_http_headers); - extract_option!(ob, builder, exclusion); - Ok(builder) - } -} - // ===== impl Proxy ===== #[pymethods] diff --git a/src/tls.rs b/src/tls.rs index 5bcd003d..991d9a7b 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -2,6 +2,7 @@ mod identity; mod keylog; mod store; +use derive_deftly::Deftly; use pyo3::prelude::*; use wreq::tls::compress::CertificateCompressor; use wreq_util::emulate::compress; @@ -112,7 +113,9 @@ define_enum!( ); /// A builder for [`TlsOptions`]. -#[derive(Default)] +#[derive(Default, Deftly)] +#[derive_deftly(ExtractOptions)] +#[deftly(deny_unknown)] struct Builder { /// Application-Layer Protocol Negotiation ([RFC 7301](https://datatracker.ietf.org/doc/html/rfc7301)). /// @@ -190,16 +193,13 @@ struct Builder { /// List of supported elliptic curves. curves_list: Option, - /// List of supported signature algorithms. - sigalgs_list: Option, - /// Cipher suite configuration string. /// /// Uses BoringSSL's mini-language to select, enable, and prioritize ciphers. cipher_list: Option, - /// Sets whether to preserve the TLS 1.3 cipher list as configured by [`Self::cipher_list`]. - preserve_tls13_cipher_list: Option, + /// List of supported signature algorithms. + sigalgs_list: Option, /// Supported certificate compression algorithms ([RFC 8879](https://datatracker.ietf.org/doc/html/rfc8879)). certificate_compression_algorithms: Option>, @@ -210,45 +210,13 @@ struct Builder { /// Overrides AES hardware acceleration. aes_hw_override: Option, + /// Sets whether to preserve the TLS 1.3 cipher list as configured by [`Self::cipher_list`]. + preserve_tls13_cipher_list: Option, + /// Overrides the random AES hardware acceleration. random_aes_hw_override: Option, } -impl FromPyObject<'_, '_> for Builder { - type Error = PyErr; - - fn extract(ob: Borrowed) -> PyResult { - let mut params = Self::default(); - extract_option!(ob, params, alpn_protocols); - extract_option!(ob, params, alps_protocols); - extract_option!(ob, params, alps_use_new_codepoint); - extract_option!(ob, params, session_ticket); - extract_option!(ob, params, min_tls_version); - extract_option!(ob, params, max_tls_version); - extract_option!(ob, params, pre_shared_key); - extract_option!(ob, params, enable_ech_grease); - extract_option!(ob, params, permute_extensions); - extract_option!(ob, params, grease_enabled); - extract_option!(ob, params, enable_ocsp_stapling); - extract_option!(ob, params, enable_signed_cert_timestamps); - extract_option!(ob, params, record_size_limit); - extract_option!(ob, params, psk_skip_session_ticket); - extract_option!(ob, params, key_shares); - extract_option!(ob, params, psk_dhe_ke); - extract_option!(ob, params, renegotiation); - extract_option!(ob, params, delegated_credentials); - extract_option!(ob, params, curves_list); - extract_option!(ob, params, cipher_list); - extract_option!(ob, params, sigalgs_list); - extract_option!(ob, params, certificate_compression_algorithms); - extract_option!(ob, params, extension_permutation); - extract_option!(ob, params, aes_hw_override); - extract_option!(ob, params, preserve_tls13_cipher_list); - extract_option!(ob, params, random_aes_hw_override); - Ok(params) - } -} - /// TLS connection configuration options. /// /// This struct provides fine-grained control over the behavior of TLS @@ -259,6 +227,7 @@ impl FromPyObject<'_, '_> for Builder { /// - **Performance tuning** (record size, cipher preferences, hardware overrides) /// /// All fields are optional or have defaults. See each field for details. +/// Unknown keyword arguments raise `TypeError`. #[derive(Clone)] #[pyclass(frozen, from_py_object)] pub struct TlsOptions(pub wreq::tls::TlsOptions); diff --git a/tests/aio_test.py b/tests/aio_test.py index fd5e994d..d0761b11 100644 --- a/tests/aio_test.py +++ b/tests/aio_test.py @@ -216,7 +216,7 @@ async def aenter(self): # The response exits even when the block fails. with pytest.raises(KeyError): - async with wreq.get(url, proxies=[]) as response: + async with wreq.get(url) as response: raise KeyError with pytest.raises(RuntimeError, match="consumed"): await response.text() diff --git a/tests/runtime_test.py b/tests/runtime_test.py index 5a6bcc7b..09c44ca6 100644 --- a/tests/runtime_test.py +++ b/tests/runtime_test.py @@ -63,6 +63,61 @@ def test_runtime_configuration(): client.close() +@pytest.mark.parametrize("factory", [wreq.Client, wreq.blocking.Client]) +def test_client_rejects_unknown_options(factory): + for name in ("dns_option", "\ud800"): + with pytest.raises(TypeError, match="unexpected keyword argument"): + factory(**{name: object()}) + + +def test_protocol_and_proxy_reject_unknown_options(): + factories = ( + wreq.http1.Http1Options, + wreq.http2.Http2Options, + wreq.tls.TlsOptions, + lambda **kwargs: wreq.Proxy.all("http://127.0.0.1:8080", **kwargs), + ) + for factory in factories: + for name in ("unknown", "\ud800"): + for value in (None, object()): + with pytest.raises(TypeError, match="unexpected keyword argument"): + factory(**{name: value}) + + +def test_request_rejects_unknown_options_at_call(): + for factory, api in ((wreq.Client, wreq), (wreq.blocking.Client, wreq.blocking)): + client = factory(proxies=[]) + try: + for route in (client, api): + for operation in ( + "get", "post", "put", "patch", "delete", "head", + "options", "trace", "request", "websocket", + ): + scheme = "ws" if operation == "websocket" else "http" + url = f"{scheme}://127.0.0.1" + args = (wreq.Method.GET, url) if operation == "request" else (url,) + for options in ( + {"complete_nonsense": 1}, {"\ud800": None}, {"proxies": []}, + ): + with pytest.raises(TypeError, match="unexpected keyword argument"): + getattr(route, operation)(*args, **options) + finally: + client.close() + + +@pytest.mark.asyncio +async def test_request_value_conversion_is_deferred(): + async with wreq.Client(proxies=[]) as client: + for route in (client, wreq): + for method, url in ( + (route.get, "http://127.0.0.1"), + (route.websocket, "ws://127.0.0.1"), + ): + coroutine = method(url, headers=object()) + with pytest.raises(TypeError): + await coroutine + + @pytest.mark.asyncio @pytest.mark.parametrize("scheduler", [Scheduler.PER_WORKER, Scheduler.WORK_STEALING]) async def test_response_and_stream_keep_runtime_alive(scheduler): diff --git a/tests/upload_test.py b/tests/upload_test.py index 8bed63c8..699f0bf1 100644 --- a/tests/upload_test.py +++ b/tests/upload_test.py @@ -118,7 +118,8 @@ async def chunks(): @pytest.mark.asyncio -async def test_invalid_option_does_not_start_the_body(): +@pytest.mark.parametrize("options", [{"zstd": 1}, {"complete_nonsense": 1}]) +async def test_invalid_option_does_not_start_the_body(options): started = asyncio.Event() async def chunks(): @@ -127,12 +128,12 @@ async def chunks(): form = wreq.Multipart(wreq.Part("file", iter([b"x"]))) async with local_server() as (url, _), wreq.Client(proxies=[]) as client: - # Every option, `zstd` last, is validated before the body or form is taken. + # Unknown keys and invalid values are rejected before taking the body or form. with pytest.raises(TypeError): - await client.post(url, body=chunks(), zstd=1) + await client.post(url, body=chunks(), **options) for _ in range(2): with pytest.raises(TypeError): - await client.post(url, multipart=form, zstd=1) + await client.post(url, multipart=form, **options) await asyncio.sleep(0.1) assert not started.is_set()