Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7272a01
perf(blocking): speed up blocking requests and streams
0x676e67 Oct 4, 2026
5f48ed3
refactor(client): unify sync and async body handling
0x676e67 Oct 4, 2026
d9164b1
perf(bindings): keep the GIL for in-memory header and cookie work
0x676e67 Oct 4, 2026
564458f
docs: shorten long paths and refresh internal docs
0x676e67 Oct 4, 2026
dab9d5b
style: fmt code
0x676e67 Oct 4, 2026
6d1a999
refactor(extractor): rename BytesInput and StrInput to Binary and Text
0x676e67 Oct 4, 2026
f98ec5f
style: fmt code
0x676e67 Oct 4, 2026
45f3280
chore: sync main into blocking performance branch
0x676e67 Oct 4, 2026
29553f1
feat(aio): enter request coroutines with async with
0x676e67 Oct 4, 2026
22ccf22
Merge branch 'main' into demo/blocking-perf
0x676e67 Oct 4, 2026
1a9f212
chore: sync latest blocking performance changes
0x676e67 Oct 4, 2026
fa8a833
chore: preserve parallel PR updates
0x676e67 Oct 4, 2026
e825288
fix(aio): await the result's __aenter__ when entering request coroutines
0x676e67 Oct 4, 2026
2367c96
fix(blocking): run sync upload iterators on the blocking pool
0x676e67 Oct 4, 2026
3a7eef9
fix(blocking): confirm stream end and read unsized bodies without the…
0x676e67 Oct 4, 2026
80b48db
docs: keep quickstart reads inside the response block
0x676e67 Oct 4, 2026
e09dc34
chore: sync blocking performance fixes
0x676e67 Oct 4, 2026
f53a047
bench(blocking): refresh measurements and docs
0x676e67 Oct 4, 2026
90d46f9
docs(bench): collapse measurement environment
0x676e67 Oct 4, 2026
f4c21b7
fix(aio): drive __aenter__ like await and move async with into a scop…
0x676e67 Oct 4, 2026
d60c7b1
fix(client): park stalled sync upload pumps to free blocking threads
0x676e67 Oct 4, 2026
9b0906d
refactor: box futures with FutureExt::boxed
0x676e67 Oct 4, 2026
a959973
docs(multipart): drop redundant field docs
0x676e67 Oct 4, 2026
3784672
fix(stream): wake readers only for frames already sent
0x676e67 Oct 4, 2026
e1653f6
test(aio): close connections accepted during exchange teardown
0x676e67 Oct 4, 2026
0317dbf
test: close late connections in test servers and bound CI test hangs
0x676e67 Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Preserve raw benchmark snapshots across platform-specific newline settings.
/bench/data/**/*.json -text
/bench/data/**/*.log -text
/bench/data/**/*.txt -text
/bench/data/**/*.report.md -text
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:

tests:
runs-on: ubuntu-latest
timeout-minutes: 30
services:
httpbin:
image: kennethreitz/httpbin
Expand Down Expand Up @@ -81,7 +82,7 @@ jobs:
- name: Run tests
run: |
source .venv/bin/activate
pytest
pytest -o faulthandler_timeout=60

pypy311:
name: PyPy 3.11 (Linux x86_64)
Expand Down
17 changes: 4 additions & 13 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ serde_json = "1"
serde_urlencoded = "0.7.1"
indexmap = { version = "2.13.0", features = ["serde"] }
bytes = "1.12.1"
arc-swap = "1.9.2"
http = "1.4.2"
http-body = "1.1.0"
http-body-util = "0.1.4"
wreq = { version = "0.16.1", default-features = false, features = [
"webpki-roots",
Expand Down Expand Up @@ -79,10 +79,10 @@ tikv-jemallocator = { version = "0.7.0", default-features = false, features = [
wreq-util = { git = "https://github.com/0x676e67/wreq-util", rev = "5715529002738f46eea86a7271f918f9374d3582" }
# demo/netty-migration
wreq = { git = "https://github.com/0x676e67/wreq", rev = "54fdc7c9e0c1290e2f70f38d271ba954e3d86cc2" }
# demo/tokio-btls-vectored-write
btls = { git = "https://github.com/0x676e67/btls", rev = "ffaf0e8c23d455af4e306986ce81bb57ebd03bc2" }
btls-sys = { git = "https://github.com/0x676e67/btls", rev = "ffaf0e8c23d455af4e306986ce81bb57ebd03bc2" }
tokio-btls = { git = "https://github.com/0x676e67/btls", rev = "ffaf0e8c23d455af4e306986ce81bb57ebd03bc2" }
# demo/tokio-btls-read-records
btls = { git = "https://github.com/0x676e67/btls", rev = "0a23537f963f2de3fca63f8ac40f5923c9fbbccd" }
btls-sys = { git = "https://github.com/0x676e67/btls", rev = "0a23537f963f2de3fca63f8ac40f5923c9fbbccd" }
tokio-btls = { git = "https://github.com/0x676e67/btls", rev = "0a23537f963f2de3fca63f8ac40f5923c9fbbccd" }
# main (0x676e67/netty#145)
netty = { git = "https://github.com/0x676e67/netty", rev = "f8652ddf5e386d66b7f03cceedbc6b319d849762" }

Expand Down
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,9 @@ from wreq import Client, Emulation


async def main():
# Build a client
client = Client(emulation=Emulation.Chrome153)

# Use the API you're already familiar with
resp = await client.get("https://pingly.us.kg/api/all")
print(await resp.text())
async with Client(emulation=Emulation.Chrome154) as client:
async with client.get("https://pingly.us.kg/api/all") as response:
print(await response.text())


if __name__ == "__main__":
Expand Down
51 changes: 37 additions & 14 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,16 @@ requires CMake, Clang, and the usual native build tools.
```bash
uv venv --python 3.14
uv pip install -r bench/requirements.txt
uv run --no-sync maturin develop --release --uv --locked
uv run --no-sync maturin develop --release --uv --locked --features jemalloc
cargo build --release --locked --manifest-path bench/server/Cargo.toml
uv run --no-sync python bench/run.py \
--server bench/server/target/release/wreq-benchmark-server
```

On Windows, the server executable ends in `.exe`. If Cargo uses a custom target
directory, pass the actual executable path to `--server`.
The build command above uses jemalloc on macOS and Linux. On Windows, use the
platform allocator or the supported `mimalloc` feature instead; do not enable
both allocator features. The server executable ends in `.exe` on Windows. If
Cargo uses a custom target directory, pass the actual executable path to `--server`.

`bench/run.py` passes workload options to the measurement runner. Before it
starts, it prints the number of cases and batches. It saves stdout/stderr logs
Expand All @@ -73,9 +75,10 @@ number of warm-up batches. Allow several hours for a full run. The setup command
above prepare the dependencies and native binaries; the runner won't install or
build them for you. Publishing the results to the docs is optional.

The saved WSL measurements built wreq with `--features jemalloc` in addition
to the release flags above. Check a run's build records for its allocator and
toolchain. The setup command above uses the platform's default allocator.
Use the same allocator and release flags for both wreq revisions in a comparison,
and record them in the run's build provenance. jemalloc controls Rust allocations
in the wreq extension, not Python's allocator or those of the other clients.
The saved WSL measurements also used `--features jemalloc`.

For an integration check, use the same clients and protocols with fewer
requests. This smoke run checks behavior; it isn't enough to rank performance:
Expand All @@ -85,7 +88,7 @@ uv run --no-sync python bench/run.py \
--server bench/server/target/release/wreq-benchmark-server \
--sizes 10240,1048576 --concurrency 2 --requests 4 \
--rounds 1 --warmup 0 --samples 1 --output bench/data/smoke/RUN.json
uv run --no-sync python -m pytest bench
uv run --no-sync python -m pytest bench/tests
```

Default `pytest` runs only `tests/`. The explicit command above checks the
Expand Down Expand Up @@ -124,6 +127,10 @@ also doesn't isolate TLS I/O improvements; check the build and harness records
before attributing a change. Omit `--output` to print UTF-8 Markdown to stdout.
File exports refuse to overwrite any existing path, including the input JSON.

Add `--api blocking` to compare a full snapshot with a blocking-only rerun.
Both original snapshots are validated; only the report is filtered. Workload,
environment, server and the selected client metadata must still match.

## Stored data and documentation

We run the benchmarks locally, outside GitHub Actions, and keep JSON snapshots
Expand All @@ -140,28 +147,44 @@ uv run --no-sync python bench/run.py --input bench/data/RUN.json --publish

`--input` reads a saved run and generates its report, or reuses the report if its
contents match. It never starts a measurement. With `--publish`, the runner also
freezes the candidate JSON and builds the docs. Only a successful build lets it
atomically select the original bytes as `bench/data/latest.json`. Historical
JSON, logs and reports stay untouched.
freezes the candidate JSON and any selected blocking snapshot, then builds the docs.
After a successful build, it atomically selects the original bytes as
`bench/data/latest.json`. Historical JSON, logs and reports stay untouched.

To update only the blocking results without rerunning the async clients:

```bash
uv run --no-sync python bench/run.py --input bench/data/BLOCKING.json --publish-blocking
```

This requires all seven blocking clients and the same complete workload and
batch minimums. It freezes the candidate and existing `latest.json` for the docs
build, then selects `bench/data/latest-blocking.json` only if the build succeeds.
The async source and all historical files stay unchanged. The two publication
options cannot be combined.

Smoke runs belong in `bench/data/smoke/` and cannot be published. Publication
requires the complete default matrix, at least 300 requests per batch and
three rounds, with at least one warm-up and timed sample per round. These checks
confirm coverage; you still need to review the quality of the measurements.
requires the complete default or blocking matrix, at least 300 requests per
batch and three rounds, with at least one warm-up and timed sample per round.
These checks confirm coverage; you still need to review the quality of the measurements.

Use `--build-docs` instead of `--publish` to preview a complete recorded matrix
without changing `latest.json`. If your docs dependencies are in a separate
virtual environment, add `--docs-python PATH/TO/python`. You can also pass
`--publish` to a new measurement to run those steps after it finishes. The
runner doesn't commit, push or enable benchmarks in CI.

`python docs/build.py` reads and validates the checked-in `bench/data/latest.json`.
`python docs/build.py` reads and validates the checked-in `bench/data/latest.json`
and, when present, `latest-blocking.json`. The page keeps each source's revision
and environment separate; no combined measurement JSON is created.
It generates responsive light/dark SVG charts and fills
`docs/templates/benchmark.md` with body-size controls and measurement details.
The built site includes a frozen raw JSON copy. The build won't fetch
measurements or start a benchmark, and missing or invalid data stops it.
Read the Docs uses this same entry point.

Use `python docs/build.py --data bench/data/RUN.json` to preview another run.
This standalone preview does not load the selected blocking overlay.
For blocking-only data, use `--blocking-data bench/data/BLOCKING.json` instead.
Each measurement shows its source revision and whether the checkout had
uncommitted changes. Saving data on `main` doesn't change which code was tested.
7 changes: 5 additions & 2 deletions bench/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,12 @@ def _render_svg(document, case, rows, theme, mobile):
return "\n".join(parts) + "\n"


def write_charts(document: dict, directory: Path) -> dict:
def write_charts(document: dict, directory: Path, *, api: str | None = None) -> dict:
"""Write four SVG variants per configured case, without deleting any files."""
validate_document(document)
if api not in (None, "async", "blocking"):
raise ValueError("Chart API must be async or blocking")
apis = (api,) if api is not None else ("async", "blocking")
config = document["configuration"]
ordered = [client for client in LABELS if client in config["clients"]]
ordered += [client for client in config["clients"] if client not in LABELS]
Expand All @@ -267,7 +270,7 @@ def write_charts(document: dict, directory: Path) -> dict:
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
cases = []
for api in ("async", "blocking"):
for api in apis:
clients = [client for client in ordered if CAPABILITIES[client]["api"] == api]
if not clients:
continue
Expand Down
41 changes: 33 additions & 8 deletions bench/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,23 @@ def comparable_client(metadata):
return result


def render_comparison(before: dict, after: dict) -> str:
def render_comparison(before: dict, after: dict, *, api: str | None = None) -> str:
for document in (before, after):
validate_document(document)
config = before["configuration"]
if config != after["configuration"]:
if api not in (None, "async", "blocking"):
raise ValueError("Comparison API must be async or blocking")
configurations = [dict(document["configuration"]) for document in (before, after)]
if api is not None:
for config in configurations:
config["clients"] = sorted(
client
for client in config["clients"]
if CAPABILITIES[client]["api"] == api
)
if not config["clients"]:
raise ValueError(f"Snapshot contains no {api} clients")
config = configurations[0]
if config != configurations[1]:
raise ValueError("Benchmark configurations do not match")
fields = (
"python",
Expand All @@ -60,7 +72,11 @@ def render_comparison(before: dict, after: dict) -> str:
):
raise ValueError(f"Benchmark client metadata does not match: {client}")
cells = [
{tuple(cell[key] for key in DIMENSIONS): cell for cell in document["results"]}
{
tuple(cell[key] for key in DIMENSIONS): cell
for cell in document["results"]
if cell["client"] in config["clients"]
}
for document in (before, after)
]
if cells[0].keys() != cells[1].keys():
Expand All @@ -76,8 +92,8 @@ def render_comparison(before: dict, after: dict) -> str:
date = datetime.fromisoformat(document["generated_at"]).astimezone(timezone.utc)
wreq = [
metadata
for metadata in document["clients"].values()
if metadata["package"] == "wreq"
for client, metadata in document["clients"].items()
if client in config["clients"] and metadata["package"] == "wreq"
]
versions = ", ".join(sorted({metadata["version"] for metadata in wreq}))
hashes = ", ".join(
Expand All @@ -95,10 +111,16 @@ def render_comparison(before: dict, after: dict) -> str:
f"{'Yes' if source['dirty'] else 'No'} | {escape(date.isoformat())} | "
f"{escape(versions or 'Not recorded')} | {escape(hashes or 'Not recorded')} |"
)
if api is not None:
lines += [
"",
f"Comparison scope: {api} clients only; original snapshots are unchanged.",
]
requests = config["requests"] * config["samples"] * config["rounds"]
lines += [
"",
f"Each snapshot contains {len(cells[0]):,} supported cells, with "
f"Each snapshot {'contributes' if api is not None else 'contains'} "
f"{len(cells[0]):,} supported cells{' to this comparison' if api is not None else ''}, with "
f"{requests:,} timed requests per cell across {config['rounds']} rounds. "
"Counts come from the cases recorded in these snapshots.",
"",
Expand Down Expand Up @@ -186,13 +208,16 @@ def main(argv=None) -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--before", type=Path, required=True)
parser.add_argument("--after", type=Path, required=True)
parser.add_argument(
"--api", choices=("async", "blocking"), help="Compare only this API group"
)
parser.add_argument("--output", type=Path, help="Write a new UTF-8 Markdown file")
args = parser.parse_args(argv)
documents = [
json.loads(path.read_text(encoding="utf-8"))
for path in (args.before, args.after)
]
content = render_comparison(*documents)
content = render_comparison(*documents, api=args.api)
if args.output is not None:
with args.output.open("x", encoding="utf-8", newline="\n") as handle:
handle.write(content)
Expand Down
Loading
Loading