From 6f98db8e3792e0abba1d7a5a265558d37181de73 Mon Sep 17 00:00:00 2001 From: patrick-immometrica Date: Sun, 8 Nov 2026 18:30:22 +0100 Subject: [PATCH 1/2] perf(json): match json, query and form values by type --- src/client/body/json.rs | 47 +++++++++++++++++++++++++++++++++++++++-- src/client/param.rs | 35 ++++++++++++++++++++++++++++-- tests/request_test.py | 34 +++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/client/body/json.rs b/src/client/body/json.rs index dcdb91ca..138c630d 100644 --- a/src/client/body/json.rs +++ b/src/client/body/json.rs @@ -1,7 +1,12 @@ use std::fmt; use indexmap::IndexMap; -use pyo3::{FromPyObject, prelude::*, pybacked::PyBackedStr}; +use pyo3::{ + FromPyObject, + prelude::*, + pybacked::PyBackedStr, + types::{PyBool, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple}, +}; use serde::{ Deserialize, Deserializer, Serialize, Serializer, de::{Error, MapAccess, SeqAccess, Visitor}, @@ -9,7 +14,7 @@ use serde::{ /// Represents a JSON value for HTTP requests. /// Supports objects, arrays, numbers, strings, booleans, and null. -#[derive(FromPyObject, IntoPyObject, Serialize)] +#[derive(IntoPyObject, Serialize)] #[serde(untagged)] pub enum Json { Object(IndexMap), @@ -35,6 +40,44 @@ struct JsonVisitor; // ===== impl JsonString ===== +impl FromPyObject<'_, '_> for Json { + type Error = PyErr; + + fn extract(ob: Borrowed) -> PyResult { + if ob.is_instance_of::() { + return ob.extract().map(Json::String); + } + if ob.is_instance_of::() { + return ob.extract().map(Json::Boolean); + } + if ob.is_instance_of::() { + return ob + .extract() + .map(Json::Number) + .or_else(|_| ob.extract().map(Json::Float)); + } + if ob.is_instance_of::() { + return ob.extract().map(Json::Float); + } + if ob.is_none() { + return Ok(Json::Null(None)); + } + if ob.is_instance_of::() { + return ob.extract().map(Json::Object); + } + if ob.is_instance_of::() || ob.is_instance_of::() { + return ob.extract().map(Json::Array); + } + ob.extract() + .map(Json::Object) + .or_else(|_| ob.extract().map(Json::Boolean)) + .or_else(|_| ob.extract().map(Json::Number)) + .or_else(|_| ob.extract().map(Json::Float)) + .or_else(|_| ob.extract().map(Json::String)) + .or_else(|_| ob.extract().map(Json::Array)) + } +} + impl FromPyObject<'_, '_> for JsonString { type Error = PyErr; diff --git a/src/client/param.rs b/src/client/param.rs index 3a7f8e24..f38c6dc6 100644 --- a/src/client/param.rs +++ b/src/client/param.rs @@ -1,5 +1,10 @@ use indexmap::IndexMap; -use pyo3::{FromPyObject, pybacked::PyBackedStr}; +use pyo3::{ + FromPyObject, + prelude::*, + pybacked::PyBackedStr, + types::{PyBool, PyFloat, PyInt, PyString}, +}; use serde::{ Serialize, Serializer, ser::{SerializeMap, SerializeSeq}, @@ -15,7 +20,6 @@ pub enum Params { /// A scalar parameter value: `bool`, `int`, `float` or `str`, serialized as its text form /// (`true`/`false` for booleans). -#[derive(FromPyObject)] pub enum ParamValue { Boolean(bool), Number(isize), @@ -23,6 +27,33 @@ pub enum ParamValue { String(PyBackedStr), } +impl FromPyObject<'_, '_> for ParamValue { + type Error = PyErr; + + fn extract(ob: Borrowed) -> PyResult { + if ob.is_instance_of::() { + return ob.extract().map(ParamValue::String); + } + if ob.is_instance_of::() { + return ob.extract().map(ParamValue::Boolean); + } + if ob.is_instance_of::() { + return ob + .extract() + .map(ParamValue::Number) + .or_else(|_| ob.extract().map(ParamValue::Float64)); + } + if ob.is_instance_of::() { + return ob.extract().map(ParamValue::Float64); + } + ob.extract() + .map(ParamValue::Boolean) + .or_else(|_| ob.extract().map(ParamValue::Number)) + .or_else(|_| ob.extract().map(ParamValue::Float64)) + .or_else(|_| ob.extract().map(ParamValue::String)) + } +} + impl Serialize for ParamValue { fn serialize(&self, serializer: S) -> Result where diff --git a/tests/request_test.py b/tests/request_test.py index a39112a5..421fa945 100644 --- a/tests/request_test.py +++ b/tests/request_test.py @@ -1,3 +1,5 @@ +import asyncio + import pytest import wreq from wreq import Version @@ -205,3 +207,35 @@ def file_to_bytes_stream(file_path): async with resp: json = await resp.json() assert json["data"] in open("README.md").read() + + +@pytest.mark.asyncio +async def test_json_and_query_value_types(): + requests = asyncio.Queue() + + async def handle(reader, writer): + head = await reader.readuntil(b"\r\n\r\n") + length = next( + (int(line.split(b":")[1]) for line in head.split(b"\r\n") if line.lower().startswith(b"content-length:")), + 0, + ) + requests.put_nowait(head.split(b" ")[1] + b" " + await reader.readexactly(length)) + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + await writer.drain() + writer.close() + + server = await asyncio.start_server(handle, "127.0.0.1", 0) + url = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/" + body = {"b": True, "i": 1, "f": 1.5, "s": "x", "n": None, "l": [1, "a"], "t": (False,), "o": {"k": {}}} + try: + await (await client.post(url, json=body)).close() + await (await client.get(url, query={"b": False, "i": 2, "f": 0.5, "s": "y"})).close() + sent = [await asyncio.wait_for(requests.get(), 5) for _ in range(2)] + finally: + server.close() + await server.wait_closed() + + assert sent == [ + b'/ {"b":true,"i":1,"f":1.5,"s":"x","n":null,"l":[1,"a"],"t":[false],"o":{"k":{}}}', + b"/?b=false&i=2&f=0.5&s=y ", + ] From bbe4756d15a71e4ba9d43ddca74580e6b142d7af Mon Sep 17 00:00:00 2001 From: patrick-immometrica Date: Sun, 8 Nov 2026 19:16:47 +0100 Subject: [PATCH 2/2] test: wrap long lines --- tests/request_test.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/request_test.py b/tests/request_test.py index 421fa945..01a2522c 100644 --- a/tests/request_test.py +++ b/tests/request_test.py @@ -216,20 +216,39 @@ async def test_json_and_query_value_types(): async def handle(reader, writer): head = await reader.readuntil(b"\r\n\r\n") length = next( - (int(line.split(b":")[1]) for line in head.split(b"\r\n") if line.lower().startswith(b"content-length:")), + ( + int(line.split(b":")[1]) + for line in head.split(b"\r\n") + if line.lower().startswith(b"content-length:") + ), 0, ) - requests.put_nowait(head.split(b" ")[1] + b" " + await reader.readexactly(length)) - writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + requests.put_nowait( + head.split(b" ")[1] + b" " + await reader.readexactly(length) + ) + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) await writer.drain() writer.close() server = await asyncio.start_server(handle, "127.0.0.1", 0) url = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/" - body = {"b": True, "i": 1, "f": 1.5, "s": "x", "n": None, "l": [1, "a"], "t": (False,), "o": {"k": {}}} + body = { + "b": True, + "i": 1, + "f": 1.5, + "s": "x", + "n": None, + "l": [1, "a"], + "t": (False,), + "o": {"k": {}}, + } try: await (await client.post(url, json=body)).close() - await (await client.get(url, query={"b": False, "i": 2, "f": 0.5, "s": "y"})).close() + await ( + await client.get(url, query={"b": False, "i": 2, "f": 0.5, "s": "y"}) + ).close() sent = [await asyncio.wait_for(requests.get(), 5) for _ in range(2)] finally: server.close()